171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
/**
|
|
* Regression tests for the bot-chat tile crash observed live on 2026-08-23:
|
|
*
|
|
* [error-boundary:contrib:session-tile:20260823_213059_ed6222]
|
|
* Error: Maximum update depth exceeded. The result of getSnapshot should be
|
|
* cached to avoid an infinite loop.
|
|
* at UseTapEffects (assistant-ui internal)
|
|
* at AuiProvider
|
|
* at ChatRuntimeBoundary (src/app/chat/index.tsx)
|
|
* at TileChat / SessionTilePane (src/app/chat/session-tile.tsx)
|
|
*
|
|
* Mechanism under test: ChatRuntimeBoundary passes a FRESH adapter object
|
|
* literal to useIncrementalExternalStoreRuntime on every render. The
|
|
* [runtime, store] effect therefore re-runs each render, and
|
|
* IncrementalExternalStoreThreadRuntimeCore.__internal_setAdapter's
|
|
* "nothing changed" fast path (same isRunning + same messageRepository)
|
|
* still calls _notifySubscribers() unconditionally. A subscriber whose
|
|
* notification re-renders the boundary then produces:
|
|
* render -> new store literal -> effect -> setAdapter -> notify -> render
|
|
* an unbounded feedback loop that trips React's update-depth guard and
|
|
* takes the whole session tile down with the error boundary.
|
|
*
|
|
* The invariant pinned here: handing __internal_setAdapter an adapter that is
|
|
* a NEW OBJECT but semantically identical (same messageRepository identity,
|
|
* same isRunning, same capabilities) must NOT notify subscribers. Notify is
|
|
* for observable state changes, not for object-identity churn of the adapter
|
|
* literal — the render loop is impossible once no-op swaps are silent.
|
|
*/
|
|
import { fromThreadMessageLike, getAutoStatus } from '@assistant-ui/core/internal'
|
|
import type { ExportedMessageRepository, ExternalStoreAdapter, ThreadMessage } from '@assistant-ui/react'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { IncrementalExternalStoreRuntimeCore } from './incremental-external-store-runtime'
|
|
|
|
const STATUS = getAutoStatus(false, false, false, false, undefined)
|
|
|
|
function message(id: string, text: string): ThreadMessage {
|
|
return fromThreadMessageLike({ role: 'assistant', content: [{ type: 'text', text }] }, id, STATUS)
|
|
}
|
|
|
|
function repositoryOf(messages: ThreadMessage[]): ExportedMessageRepository {
|
|
return {
|
|
headId: messages.at(-1)?.id ?? null,
|
|
messages: messages.map((item, index) => ({
|
|
message: item,
|
|
parentId: index === 0 ? null : messages[index - 1].id
|
|
}))
|
|
}
|
|
}
|
|
|
|
function adapterWith(messageRepository: ExportedMessageRepository, extra: Partial<ExternalStoreAdapter> = {}) {
|
|
// Deliberately a fresh object each call — mirrors the inline literal in
|
|
// ChatRuntimeBoundary (src/app/chat/index.tsx) whose closures (onNew,
|
|
// onCancel, ...) are re-created per render.
|
|
return {
|
|
messageRepository,
|
|
isRunning: false,
|
|
setMessages: () => {},
|
|
onNew: async () => {},
|
|
onCancel: async () => {},
|
|
...extra
|
|
} as ExternalStoreAdapter
|
|
}
|
|
|
|
describe('IncrementalExternalStoreThreadRuntimeCore adapter swap notifications', () => {
|
|
it('does NOT notify subscribers when a new adapter object carries identical state (render-loop guard)', () => {
|
|
const repo = repositoryOf([message('a', 'one'), message('b', 'two')])
|
|
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
|
|
const thread = core.threads.getMainThreadRuntimeCore()
|
|
|
|
let notifications = 0
|
|
thread.subscribe(() => {
|
|
notifications += 1
|
|
})
|
|
|
|
// Simulate 5 renders of ChatRuntimeBoundary: each passes a NEW adapter
|
|
// literal with the SAME messageRepository identity and same isRunning.
|
|
for (let render = 0; render < 5; render += 1) {
|
|
core.setAdapter(adapterWith(repo))
|
|
}
|
|
|
|
// The live bug: this was 5 — one notify per render, each notify able to
|
|
// schedule the next render. Silent no-op swaps break the feedback loop.
|
|
expect(notifications).toBe(0)
|
|
})
|
|
|
|
it('still notifies when the message repository actually changes', () => {
|
|
const repo = repositoryOf([message('a', 'one')])
|
|
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
|
|
const thread = core.threads.getMainThreadRuntimeCore()
|
|
|
|
let notifications = 0
|
|
thread.subscribe(() => {
|
|
notifications += 1
|
|
})
|
|
|
|
const grown = repositoryOf([message('a', 'one'), message('b', 'two')])
|
|
core.setAdapter(adapterWith(grown))
|
|
|
|
expect(notifications).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('still notifies on an isRunning flip (turn start/stop must reach the thread UI)', () => {
|
|
const repo = repositoryOf([message('a', 'one')])
|
|
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
|
|
const thread = core.threads.getMainThreadRuntimeCore()
|
|
|
|
let notifications = 0
|
|
thread.subscribe(() => {
|
|
notifications += 1
|
|
})
|
|
|
|
core.setAdapter(adapterWith(repo, { isRunning: true }))
|
|
|
|
expect(notifications).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('still notifies on an isDisabled flip even when transcript and run state are unchanged', () => {
|
|
const repo = repositoryOf([message('a', 'one')])
|
|
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
|
|
const thread = core.threads.getMainThreadRuntimeCore()
|
|
|
|
let notifications = 0
|
|
thread.subscribe(() => {
|
|
notifications += 1
|
|
})
|
|
|
|
core.setAdapter(adapterWith(repo, { isDisabled: true }))
|
|
|
|
expect(notifications).toBeGreaterThan(0)
|
|
|
|
// And flipping back also notifies — but an unchanged repeat stays silent.
|
|
const beforeFlipBack = notifications
|
|
core.setAdapter(adapterWith(repo, { isDisabled: false }))
|
|
|
|
expect(notifications).toBeGreaterThan(beforeFlipBack)
|
|
|
|
const afterFlipBack = notifications
|
|
|
|
core.setAdapter(adapterWith(repo, { isDisabled: false }))
|
|
|
|
expect(notifications).toBe(afterFlipBack)
|
|
})
|
|
|
|
it('a subscriber that swaps a fresh-but-identical adapter on every notify must not recurse unboundedly', () => {
|
|
// Direct simulation of the feedback loop: the subscriber plays the role of
|
|
// React re-rendering ChatRuntimeBoundary (new literal -> setAdapter). With
|
|
// the bug, every setAdapter notifies, the subscriber re-enters setAdapter,
|
|
// and only the depth guard stops it. Fixed behavior: the first no-op swap
|
|
// is silent, so the subscriber never fires and depth stays 0.
|
|
const repo = repositoryOf([message('a', 'one')])
|
|
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
|
|
const thread = core.threads.getMainThreadRuntimeCore()
|
|
|
|
let depth = 0
|
|
thread.subscribe(() => {
|
|
depth += 1
|
|
|
|
if (depth > 25) {
|
|
throw new Error('Maximum update depth exceeded (simulated): adapter no-op swaps are notifying subscribers')
|
|
}
|
|
|
|
core.setAdapter(adapterWith(repo))
|
|
})
|
|
|
|
core.setAdapter(adapterWith(repo))
|
|
|
|
expect(depth).toBe(0)
|
|
})
|
|
})
|