90803f22a5
Review follow-up: __internal_setAdapter assigned this.isDisabled before the fast path but never fed it into the new 'changed' flag, so an isDisabled-only flip on an otherwise-identical adapter swap would have been silently swallowed. Seed 'changed' with the isDisabled comparison and add a guard test (mutation-checked: reverting the seed fails it).
285 lines
9.6 KiB
TypeScript
285 lines
9.6 KiB
TypeScript
import {
|
|
AssistantRuntimeImpl,
|
|
BaseAssistantRuntimeCore,
|
|
ExternalStoreThreadListRuntimeCore,
|
|
ExternalStoreThreadRuntimeCore,
|
|
hasUpcomingMessage
|
|
} from '@assistant-ui/core/internal'
|
|
import {
|
|
type AssistantRuntime,
|
|
type ExternalStoreAdapter,
|
|
fromThreadMessageLike,
|
|
generateId,
|
|
type ThreadMessage,
|
|
useRuntimeAdapters
|
|
} from '@assistant-ui/react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
const EMPTY_ARRAY = Object.freeze([])
|
|
|
|
const shallowEqual = (a: object, b: object): boolean => {
|
|
const aKeys = Object.keys(a)
|
|
|
|
if (aKeys.length !== Object.keys(b).length) {
|
|
return false
|
|
}
|
|
|
|
for (const key of aKeys) {
|
|
if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
|
|
|
|
/**
|
|
* Write only the items whose (message, parentId) pair actually moved.
|
|
*
|
|
* `useRuntimeMessageRepository` caches normalized ThreadMessages by source
|
|
* identity, so a settled turn keeps the SAME object across renders. That makes
|
|
* an identity check a sound "did this change?" test: during streaming exactly
|
|
* one item — the growing tail — differs, and the other N-1 writes were pure
|
|
* overhead that grew with transcript length.
|
|
*
|
|
* Returns false when the export is stale (an id in `existing` is gone, or an
|
|
* incoming message has no repository entry yet), so the caller falls back to
|
|
* the full rebuild rather than guessing.
|
|
*/
|
|
function applyChangedMessages(
|
|
repository: ExternalStoreThreadRuntimeCore['repository'],
|
|
existing: readonly { message: ThreadMessage; parentId: string | null }[],
|
|
incoming: readonly { message: ThreadMessage; parentId: string | null }[]
|
|
): boolean {
|
|
if (existing.length !== incoming.length) {
|
|
return false
|
|
}
|
|
|
|
const existingById = new Map(existing.map(item => [item.message.id, item]))
|
|
|
|
for (const item of incoming) {
|
|
const current = existingById.get(item.message.id)
|
|
|
|
if (!current) {
|
|
return false
|
|
}
|
|
|
|
// Reference identity, not deep equality: the conversion cache guarantees a
|
|
// stable object for an unchanged turn, and a changed turn is a new object.
|
|
if (current.message !== item.message || current.parentId !== item.parentId) {
|
|
repository.addOrUpdateMessage(item.parentId, item.message)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export function syncRepositoryIncrementally(
|
|
runtime: ExternalStoreThreadRuntimeCore,
|
|
messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']>
|
|
): readonly ThreadMessage[] {
|
|
const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
|
|
const incoming = messageRepository.messages
|
|
const existing = repository.export().messages
|
|
const headId = messageRepository.headId ?? incoming.at(-1)?.message.id ?? null
|
|
|
|
// A thread switch swaps in a fully-DISJOINT transcript (no id carries over).
|
|
// Reconciling two unrelated trees in place — grafting the new chain onto the
|
|
// old one, then pruning — can strand a stale head/branch, so there's nothing
|
|
// to preserve: clear the tree first (leaves→root), then rebuild clean.
|
|
const incomingIds = new Set(incoming.map(({ message }) => message.id))
|
|
const disjoint = existing.length > 0 && !existing.some(({ message }) => incomingIds.has(message.id))
|
|
|
|
// Steady-state streaming: same message set, one item changed. Skip the
|
|
// whole-transcript rewrite, the prune scan, and the second export. resetHead
|
|
// deletes the head's descendants, so it only runs when the head really moved.
|
|
if (!disjoint && applyChangedMessages(repository, existing, incoming)) {
|
|
if (repository.headId !== headId) {
|
|
repository.resetHead(headId)
|
|
}
|
|
|
|
return repository.getMessages()
|
|
}
|
|
|
|
if (disjoint) {
|
|
for (const { message } of [...existing].reverse()) {
|
|
repository.deleteMessage(message.id)
|
|
}
|
|
}
|
|
|
|
for (const { message, parentId } of incoming) {
|
|
repository.addOrUpdateMessage(parentId, message)
|
|
}
|
|
|
|
for (const { message } of repository.export().messages) {
|
|
if (!incomingIds.has(message.id)) {
|
|
repository.deleteMessage(message.id)
|
|
}
|
|
}
|
|
|
|
repository.resetHead(headId)
|
|
|
|
return repository.getMessages()
|
|
}
|
|
|
|
class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
|
|
override __internal_setAdapter(store: ExternalStoreAdapter): void {
|
|
if (!store.messageRepository) {
|
|
super.__internal_setAdapter(store)
|
|
|
|
return
|
|
}
|
|
|
|
const self = this as unknown as {
|
|
_assistantOptimisticId: null | string
|
|
_capabilities: object
|
|
_messages: readonly ThreadMessage[]
|
|
_notifyEventSubscribers: (event: string, payload: object) => void
|
|
_notifySubscribers: () => void
|
|
_store?: ExternalStoreAdapter
|
|
}
|
|
|
|
if (self._store === store) {
|
|
return
|
|
}
|
|
|
|
const isRunning = store.isRunning ?? false
|
|
const newDisabled = store.isDisabled ?? false
|
|
const disabledChanged = this.isDisabled !== newDisabled
|
|
this.isDisabled = newDisabled
|
|
|
|
const oldStore = self._store
|
|
self._store = store
|
|
|
|
// Track whether anything OBSERVABLE changed. ChatRuntimeBoundary passes a
|
|
// fresh adapter literal on every render, so identity churn of the adapter
|
|
// object itself is NOT a change — notifying on it lets a subscriber whose
|
|
// notification re-renders the boundary drive an unbounded feedback loop
|
|
// (render -> new literal -> setAdapter -> notify -> render), which React
|
|
// kills with "Maximum update depth exceeded" and takes the session tile
|
|
// down with its error boundary.
|
|
let changed = disabledChanged
|
|
|
|
if (this.extras !== store.extras) {
|
|
this.extras = store.extras
|
|
changed = true
|
|
}
|
|
|
|
const newSuggestions = store.suggestions ?? EMPTY_ARRAY
|
|
|
|
if (!shallowEqual(this.suggestions, newSuggestions)) {
|
|
this.suggestions = newSuggestions
|
|
changed = true
|
|
}
|
|
|
|
const newCapabilities = {
|
|
switchToBranch: store.setMessages !== undefined,
|
|
switchBranchDuringRun: false,
|
|
edit: store.onEdit !== undefined,
|
|
reload: store.onReload !== undefined,
|
|
cancel: store.onCancel !== undefined,
|
|
speech: store.adapters?.speech !== undefined,
|
|
dictation: store.adapters?.dictation !== undefined,
|
|
voice: store.adapters?.voice !== undefined,
|
|
unstable_copy: store.unstable_capabilities?.copy !== false,
|
|
attachments: !!store.adapters?.attachments,
|
|
feedback: !!store.adapters?.feedback,
|
|
queue: false
|
|
}
|
|
|
|
if (!shallowEqual(self._capabilities, newCapabilities)) {
|
|
self._capabilities = newCapabilities
|
|
changed = true
|
|
}
|
|
|
|
if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
|
|
// Same transcript, same run state: notify only if extras/suggestions/
|
|
// capabilities actually moved. A silent no-op swap here is what breaks
|
|
// the render feedback loop — see the render-loop guard test.
|
|
if (changed) {
|
|
self._notifySubscribers()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (self._assistantOptimisticId) {
|
|
this.repository.deleteMessage(self._assistantOptimisticId)
|
|
self._assistantOptimisticId = null
|
|
}
|
|
|
|
const messages = syncRepositoryIncrementally(this, store.messageRepository)
|
|
|
|
if (messages.length > 0) {
|
|
this.ensureInitialized()
|
|
}
|
|
|
|
if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
|
|
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
|
|
}
|
|
|
|
// metadata.isOptimistic keeps this placeholder ephemeral: core evicts
|
|
// off-branch optimistic messages on head moves and omits them from export().
|
|
if (hasUpcomingMessage(isRunning, messages)) {
|
|
const optimisticId = generateId()
|
|
this.repository.addOrUpdateMessage(
|
|
messages.at(-1)?.id ?? null,
|
|
fromThreadMessageLike({ role: 'assistant', content: [], metadata: { isOptimistic: true } }, optimisticId, {
|
|
type: 'running'
|
|
})
|
|
)
|
|
self._assistantOptimisticId = optimisticId
|
|
}
|
|
|
|
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
|
|
self._messages = this.repository.getMessages()
|
|
self._notifySubscribers()
|
|
}
|
|
}
|
|
|
|
export class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
|
|
threads: ExternalStoreThreadListRuntimeCore
|
|
|
|
constructor(adapter: ExternalStoreAdapter) {
|
|
super()
|
|
|
|
this.threads = new ExternalStoreThreadListRuntimeCore(
|
|
getThreadListAdapter(adapter),
|
|
() => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
|
|
)
|
|
}
|
|
|
|
setAdapter(adapter: ExternalStoreAdapter): void {
|
|
this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
|
|
this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
|
|
}
|
|
}
|
|
|
|
export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(
|
|
store: ExternalStoreAdapter<T>
|
|
): AssistantRuntime {
|
|
const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
|
|
|
|
// Re-sync the adapter only when it actually changes — a dep-less effect ran
|
|
// on EVERY render of the chat surface. `__internal_setAdapter` early-exits
|
|
// when the store is unchanged, so gating on [runtime, store] is behavior-
|
|
// preserving while skipping the per-render call entirely.
|
|
useEffect(() => {
|
|
runtime.setAdapter(store as ExternalStoreAdapter)
|
|
}, [runtime, store])
|
|
|
|
const { modelContext } = useRuntimeAdapters() ?? {}
|
|
|
|
useEffect(() => {
|
|
if (!modelContext) {
|
|
return undefined
|
|
}
|
|
|
|
return runtime.registerModelContextProvider(modelContext)
|
|
}, [modelContext, runtime])
|
|
|
|
return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
|
|
}
|