diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts index de03139c8a..60f1a45f63 100644 --- a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts @@ -45,6 +45,7 @@ function harness(over: { safari?: boolean slashOpen?: boolean filteredSlashCmds?: ChatSlashCommand[] + cancelMessageEdit?: () => boolean } = {}) { const inputText = ref(over.inputText ?? '') const spies = { @@ -56,6 +57,7 @@ function harness(over: { closeSlashMenu: vi.fn(), completeSlashCmd: vi.fn(), activateSlashCmd: vi.fn(), + cancelMessageEdit: vi.fn(over.cancelMessageEdit ?? (() => false)), } const api = useChatComposerShortcuts({ inputText, @@ -294,3 +296,76 @@ describe('useChatComposerShortcuts', () => { }) }) }) + +describe('Escape and message edits', () => { + it('cancels an uncommitted edit instead of clearing the composer', () => { + // #1372: edit mode empties the transcript on the first click, and Escape + // used to clear the draft and leave that empty state on screen. Cancelling + // the edit is the whole action — the composer is restored by the cancel + // itself, so Escape must not go on to blank it. + const { api, inputText, spies } = harness({ + inputText: 'B', + cancelMessageEdit: () => true, + }) + + const e = keydown({ key: 'Escape', target: field('B', 'end') }) + api.onTextareaKeydown(e) + + expect(spies.cancelMessageEdit).toHaveBeenCalledOnce() + expect(e.preventDefault).toHaveBeenCalledOnce() + expect(inputText.value).toBe('B') + }) + + it('offers the cancel even when the composer has been emptied by hand', () => { + // The old guard required a non-empty draft, so clearing the box first left + // no way out of the truncated transcript at all. + const { api, spies } = harness({ inputText: '', cancelMessageEdit: () => true }) + + api.onTextareaKeydown(keydown({ key: 'Escape', target: field('', 'end') })) + + expect(spies.cancelMessageEdit).toHaveBeenCalledOnce() + }) + + it('offers the cancel before the pending-queue guard', () => { + const { api, spies } = harness({ + inputText: 'B', + pendingQueue: QUEUE, + cancelMessageEdit: () => true, + }) + const e = keydown({ key: 'Escape', target: field('B', 'end') }) + + api.onTextareaKeydown(e) + + expect(spies.cancelMessageEdit).toHaveBeenCalledOnce() + expect(e.preventDefault).toHaveBeenCalledOnce() + }) + + it('still clears the draft when there is no edit to cancel', () => { + const { api, inputText, spies } = harness({ inputText: 'just a draft' }) + + const e = keydown({ key: 'Escape', target: field('just a draft', 'end') }) + api.onTextareaKeydown(e) + + expect(spies.cancelMessageEdit).toHaveBeenCalledOnce() + expect(inputText.value).toBe('') + expect(e.preventDefault).toHaveBeenCalledOnce() + }) + + it('leaves the slash menu Escape alone', () => { + // Escape closes the menu first; an edit underneath it is not touched until + // the menu is out of the way. + const { api, spies } = harness({ + inputText: '/co', + slashOpen: true, + filteredSlashCmds: [ + { name: '/coding', cmd: '/coding', label: '/coding', desc: '' }, + ] as unknown as ChatSlashCommand[], + cancelMessageEdit: () => true, + }) + + api.onTextareaKeydown(keydown({ key: 'Escape', target: field('/co', 'end') })) + + expect(spies.closeSlashMenu).toHaveBeenCalledOnce() + expect(spies.cancelMessageEdit).not.toHaveBeenCalled() + }) +}) diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts index 91d37cdaee..a9ff6d698b 100644 --- a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts +++ b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts @@ -23,6 +23,13 @@ export interface UseChatComposerShortcutsOptions { popPendingTail: () => boolean enqueuePendingInput: (text: string) => boolean | Promise sendCurrentInput: () => void + /** + * Undo an uncommitted message edit, returning whether it had one to undo. + * Escape has to offer this before it clears the composer: edit mode has no + * other exit, and clearing the draft on its own leaves the truncated + * transcript on screen (#1372). + */ + cancelMessageEdit?: () => boolean isSafariWebKit?: () => boolean } @@ -116,12 +123,26 @@ export function useChatComposerShortcuts(options: UseChatComposerShortcutsOption } } - if (e.key === 'Escape' && !options.isStreaming.value && options.pendingQueue.value.length === 0 && options.inputText.value) { - e.preventDefault() - clearTextareaUndoState() - options.inputText.value = '' - options.autoResizeTextarea() - return + if (e.key === 'Escape') { + // An uncommitted edit outranks clearing the draft, and is checked before + // the non-empty-input guard below: emptying the composer by hand must not + // strand the user in a truncated transcript with no way out. + if (options.cancelMessageEdit?.()) { + e.preventDefault() + clearTextareaUndoState() + return + } + if ( + !options.isStreaming.value + && options.pendingQueue.value.length === 0 + && options.inputText.value + ) { + e.preventDefault() + clearTextareaUndoState() + options.inputText.value = '' + options.autoResizeTextarea() + return + } } if (e.key === 'ArrowUp' && e.altKey && caretAtStart && options.pendingQueue.value.length > 0) { diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts index 32d4a8f0e4..45b7fa71d6 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts @@ -1955,6 +1955,81 @@ describe('useChatHistory canonical pagination', () => { ) }) + it('defers a forward-bridge result when Edit starts during an after-page read', async () => { + vi.useFakeTimers() + try { + let resolveBridge!: (value: SessionReadHistoryPageFixture) => void + const bridgeResponse = new Promise(resolve => { + resolveBridge = resolve + }) + const { api, readHistory, historyFixture, messages } = makeHistory(false) + historyFixture + .mockResolvedValueOnce({ + messages: [historyMessage('m1')], + hasMore: true, + oldestCursor: 'cursor-1', + newestCursor: 'cursor-1', + canonicalAvailable: true, + }) + .mockResolvedValueOnce({ + messages: [historyMessage('m0')], + hasMore: false, + oldestCursor: 'cursor-0', + newestCursor: 'cursor-0', + canonicalAvailable: true, + }) + .mockResolvedValueOnce({ + messages: [historyMessage('m9')], + hasMore: false, + oldestCursor: 'cursor-9', + newestCursor: 'cursor-9', + canonicalAvailable: true, + }) + .mockImplementationOnce(() => bridgeResponse) + .mockResolvedValueOnce({ + messages: [historyMessage('m9')], + hasMore: false, + oldestCursor: 'cursor-9', + newestCursor: 'cursor-9', + canonicalAvailable: true, + }) + .mockResolvedValueOnce({ + messages: [historyMessage('m2'), historyMessage('m9')], + hasMore: false, + oldestCursor: 'cursor-2', + newestCursor: 'cursor-9', + canonicalAvailable: true, + }) + + await api.loadHistory() + await api.loadEarlierHistory() + const refresh = api.loadHistory() + await vi.waitFor(() => expect(readHistory).toHaveBeenCalledTimes(4)) + const editOwnerRef = messages.value + api.holdHistorySync() + resolveBridge({ + messages: [historyMessage('m2'), historyMessage('m9')], + hasMore: false, + oldestCursor: 'cursor-2', + newestCursor: 'cursor-9', + canonicalAvailable: true, + }) + await refresh + + expect(messages.value).toBe(editOwnerRef) + expect(messages.value.map(message => message.messageId)).toEqual(['m0', 'm1']) + + api.releaseHistorySync() + await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(0) + + expect(readHistory).toHaveBeenCalledTimes(6) + expect(messages.value.map(message => message.messageId)).toEqual(['m0', 'm1', 'm2', 'm9']) + } finally { + vi.useRealTimers() + } + }) + it('bounds each disconnected forward bridge and resumes from the saved cursor', async () => { const { api, readHistory, historyFixture, messages } = makeHistory(false) historyFixture @@ -2405,6 +2480,129 @@ describe('useChatHistory canonical pagination', () => { } }) + it('pauses a terminal history timer until Edit releases it', async () => { + vi.useFakeTimers() + try { + const initialEditOwner: ChatMessage[] = [{ + role: 'user', + text: 'edit-owned transcript', + ts: null, + messageId: 'edit-owner', + }] + const { api, readHistory, messages } = makeHistory(false, { + messages: initialEditOwner, + response: { + messages: [historyMessage('canonical-after-escape')], + hasMore: false, + oldestCursor: null, + }, + }) + const editOwner = messages.value + + api.scheduleHistorySync() + api.holdHistorySync() + await vi.advanceTimersByTimeAsync(50) + + expect(readHistory).not.toHaveBeenCalled() + expect(messages.value).toBe(editOwner) + + api.releaseHistorySync() + await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(0) + + expect(readHistory).toHaveBeenCalledOnce() + expect(messages.value.map(message => message.messageId)).toEqual([ + 'canonical-after-escape', + ]) + } finally { + vi.useRealTimers() + } + }) + + it('drops an old Edit hold at a session boundary so the new draft can sync', async () => { + vi.useFakeTimers() + try { + const sessionKey = ref('agent:main:webchat:old') + const { api, readHistory, historyFixture, messages } = makeHistory(false, { sessionKey }) + historyFixture.mockResolvedValueOnce({ + messages: [historyMessage('new-session-terminal')], + hasMore: false, + oldestCursor: null, + }) + + api.holdHistorySync() + api.scheduleHistorySync() + sessionKey.value = 'agent:main:webchat:new-draft' + api.releaseHistorySync() + + api.scheduleHistorySync() + await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(0) + + expect(readHistory).toHaveBeenCalledOnce() + expect(messages.value.map(message => message.messageId)).toEqual([ + 'new-session-terminal', + ]) + } finally { + vi.useRealTimers() + } + }) + + it('defers an in-flight history replacement until Edit releases it', async () => { + vi.useFakeTimers() + try { + let resolveWhileEditing!: (value: SessionReadHistoryPageFixture) => void + const responseWhileEditing = new Promise(resolve => { + resolveWhileEditing = resolve + }) + const initialEditOwner: ChatMessage[] = [{ + role: 'user', + text: 'edit-owned transcript', + ts: null, + messageId: 'edit-owner', + }] + const { api, readHistory, historyFixture, messages } = makeHistory(false, { + messages: initialEditOwner, + }) + const editOwner = messages.value + historyFixture + .mockImplementationOnce(() => responseWhileEditing) + .mockResolvedValueOnce({ + messages: [historyMessage('canonical-after-escape')], + hasMore: false, + oldestCursor: null, + }) + + // A terminal schedules the sync; Edit starts after its timer has already + // launched the read but before that read can replace the transcript. + api.scheduleHistorySync() + await vi.advanceTimersByTimeAsync(50) + expect(readHistory).toHaveBeenCalledOnce() + api.holdHistorySync() + resolveWhileEditing({ + messages: [historyMessage('canonical-during-edit')], + hasMore: false, + oldestCursor: null, + }) + await vi.advanceTimersByTimeAsync(0) + + expect(messages.value).toBe(editOwner) + expect(messages.value.map(message => message.messageId)).toEqual(['edit-owner']) + + // Escape releases the hold and exactly one deferred refresh applies. + api.releaseHistorySync() + await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(0) + + expect(readHistory).toHaveBeenCalledTimes(2) + expect(messages.value.map(message => message.messageId)).toEqual([ + 'canonical-after-escape', + ]) + } finally { + vi.useRealTimers() + } + }) + it('keeps the new session loading when a stale request fails first', async () => { const sessionKey = ref('agent:main:webchat:old') let rejectOld!: (reason: Error) => void diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.ts b/opensquilla-webui/src/composables/chat/useChatHistory.ts index d0f05a15b0..bca7e28755 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.ts @@ -729,6 +729,8 @@ export function useChatHistory(options: UseChatHistoryOptions) { let historySyncPending = false let historySyncTimerNonReconnecting = false let historySyncPendingNonReconnecting = false + let historySyncHeld = false + let historySyncHoldSessionKey = '' // Exposed read-only by convention so session hand-offs can distinguish the // prior session's terminal `ready` state from the new session's first load. const historySessionKey = ref('') @@ -781,6 +783,11 @@ export function useChatHistory(options: UseChatHistoryOptions) { function armHistorySync(nonReconnecting: boolean, advanceGeneration: boolean) { if (nonReconnecting && advanceGeneration) preserveLocalTailGeneration += 1 + if (historySyncHeld) { + historySyncPending = true + historySyncPendingNonReconnecting ||= nonReconnecting + return + } historySyncTimerNonReconnecting ||= nonReconnecting if (historySyncTimer) clearTimeout(historySyncTimer) historySyncTimer = setTimeout(() => { @@ -800,7 +807,32 @@ export function useChatHistory(options: UseChatHistoryOptions) { armHistorySync(preserveLocalTail, true) } + function holdHistorySync() { + if (!historySyncHeld) historySyncHoldSessionKey = options.sessionKey.value + historySyncHeld = true + if (!historySyncTimer) return + clearTimeout(historySyncTimer) + historySyncTimer = null + historySyncPending = true + historySyncPendingNonReconnecting ||= historySyncTimerNonReconnecting + historySyncTimerNonReconnecting = false + } + + function releaseHistorySync() { + historySyncHeld = false + if (historySyncHoldSessionKey !== options.sessionKey.value) { + historySyncHoldSessionKey = '' + historySyncPending = false + historySyncPendingNonReconnecting = false + loadEarlierPending = false + return + } + historySyncHoldSessionKey = '' + flushPendingHistorySync() + } + function flushPendingHistorySync() { + if (historySyncHeld) return if (historyState.value.loading || failedHistoryRequest) return if (loadEarlierPending) { loadEarlierPending = false @@ -934,6 +966,11 @@ export function useChatHistory(options: UseChatHistoryOptions) { const crossedSession = Boolean(historySessionKey.value) if (crossedSession) { acknowledgedPreserveLocalTailGeneration = preserveLocalTailGeneration + // Edit ownership belongs to the old transcript domain. The surrounding + // session transition cancels its reads; never carry its apply hold into + // the new session's bootstrap. + historySyncHeld = false + historySyncHoldSessionKey = '' } historySessionKey.value = key hasLoadedEarlier = false @@ -1049,6 +1086,16 @@ export function useChatHistory(options: UseChatHistoryOptions) { failedHistoryRequest = failedHistoryRequestBeforeLoad historyState.value = historyStateBeforeLoad } + const deferHeldHistoryRequest = (): boolean => { + if (!historySyncHeld) return false + if (params.prepend) loadEarlierPending = true + else { + historySyncPending = true + historySyncPendingNonReconnecting ||= nonReconnecting + } + restoreSilentBackgroundState() + return true + } try { if (!lease) throw new Error('No active session read lease.') if (!isCurrentRequest()) { @@ -1074,6 +1121,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { bootstrap, ) if (!isCurrentRequest()) return { ok: false, cancelled: true } + if (deferHeldHistoryRequest()) return { ok: false, cancelled: true } const msgs = data.messages const canonicalAvailable = data.canonicalAvailable if (canonicalAvailable === false) { @@ -1161,6 +1209,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { bootstrap, ) if (!isCurrentRequest()) return { ok: false, cancelled: true } + if (deferHeldHistoryRequest()) return { ok: false, cancelled: true } const bridgeAvailable = bridgeData.canonicalAvailable if (bridgeAvailable === false) { if (nonReconnecting) { @@ -1240,6 +1289,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { } if (canonicalAvailable !== false) failedHistoryRequest = null + if (deferHeldHistoryRequest()) return { ok: false, cancelled: true } // Gate the full-session error on explicit coverage metadata. Older // Gateways used canonical_available=false for a legitimate empty WebChat // session but did not yet publish canonical_complete. @@ -1590,6 +1640,8 @@ export function useChatHistory(options: UseChatHistoryOptions) { retryHistory, markSessionMissing, scheduleHistorySync, + holdHistorySync, + releaseHistorySync, cancelAnchorStabilization, cancelActiveHistory, cleanup, diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index 8e2b3c9fd9..e22a1ab311 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -65,9 +65,12 @@ function makeOptions( opts?: { assistantBoundary?: boolean }, ) => string = text => text, aiGeneratedLabel?: () => string, + overrides: Partial = {}, ) { + const sessionKey = ref('agent:main:webchat:A') const pendingForkBeforeMessageId = ref(null) const options: UseChatMessageActionsOptions = { + sessionKey, messages: ref(messages), inputText: ref(''), isStreaming: ref(false), @@ -82,8 +85,9 @@ function makeOptions( notifyMessagePending: vi.fn(), canDeliver: () => true, notifyDeliveryBlocked: vi.fn(), + ...overrides, } - return { api: useChatMessageActions(options), options, pendingForkBeforeMessageId } + return { api: useChatMessageActions(options), options, sessionKey, pendingForkBeforeMessageId } } beforeEach(() => { @@ -113,6 +117,410 @@ describe('useChatMessageActions branching edits', () => { expect(options.focusComposer).toHaveBeenCalledOnce() }) + it('puts the transcript and the draft back when the edit is cancelled', () => { + // #1372: entering edit mode empties the transcript on the first click. + // Without a way back, Escape cleared the composer and left the empty state + // on screen, which reads as the conversation having been deleted. + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + { role: 'assistant', text: 'ack B', ts: null, messageId: 'msg-b1' }, + ]) + options.inputText.value = 'half-written draft' + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 2, + messageId: 'msg-B', + text: 'B', + })) + expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A']) + + expect(api.cancelEdit()).toBe(true) + + expect(options.messages.value.map(message => message.text)).toEqual([ + 'A', 'ack A', 'B', 'ack B', + ]) + // The draft the edit overwrote is part of what was lost, so it comes back + // too rather than the composer being left holding the edited message. + expect(options.inputText.value).toBe('half-written draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('reports nothing to cancel when no edit is in flight', () => { + const { api, options } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + ]) + options.inputText.value = 'just a draft' + + // Escape distinguishes the two: a false here is what lets it fall through + // to clearing the composer instead of swallowing the key. + expect(api.cancelEdit()).toBe(false) + expect(options.inputText.value).toBe('just a draft') + expect(options.messages.value.map(message => message.text)).toEqual(['A']) + }) + + it('cancels only once, so a later Escape cannot resurrect the transcript', () => { + const { api, options } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ]) + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 2, + messageId: 'msg-B', + text: 'B', + })) + expect(api.cancelEdit()).toBe(true) + options.messages.value = [{ role: 'user', text: 'sent since', ts: null, messageId: 'msg-C' }] + + expect(api.cancelEdit()).toBe(false) + expect(options.messages.value.map(message => message.text)).toEqual(['sent since']) + }) + + it('drops the restore point once the fork id has been consumed', () => { + // Sending makes the truncation real. `pendingForkBeforeMessageId` moving + // off the edit's id is the evidence, and restoring past it would put back + // messages the fork has already replaced. + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ]) + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 2, + messageId: 'msg-B', + text: 'B', + })) + pendingForkBeforeMessageId.value = null + + expect(api.cancelEdit()).toBe(false) + expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A']) + }) + + it('drops the restore point across a session switch, including after switching back', () => { + const { api, options, sessionKey } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + ]) + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 0, + messageId: 'msg-A', + text: 'A', + })) + + sessionKey.value = 'agent:main:webchat:B' + options.messages.value = [ + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ] + options.inputText.value = 'session B draft' + + expect(api.cancelEdit()).toBe(false) + expect(options.messages.value.map(message => message.text)).toEqual(['B']) + expect(options.inputText.value).toBe('session B draft') + + sessionKey.value = 'agent:main:webchat:A' + expect(api.cancelEdit()).toBe(false) + expect(options.messages.value.map(message => message.text)).toEqual(['B']) + expect(options.inputText.value).toBe('session B draft') + }) + + it('settles an active Edit exactly once when its session changes', () => { + const onEditStarted = vi.fn() + const onEditSettled = vi.fn() + const { api, sessionKey } = makeOptions( + [ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + ], + text => text, + undefined, + { onEditStarted, onEditSettled }, + ) + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 0, + messageId: 'msg-A', + text: 'A', + })) + expect(onEditStarted).toHaveBeenCalledOnce() + + sessionKey.value = 'agent:main:webchat:new-draft' + expect(api.editActive.value).toBe(false) + expect(onEditSettled).toHaveBeenCalledOnce() + + sessionKey.value = 'agent:main:webchat:next' + expect(onEditSettled).toHaveBeenCalledOnce() + expect(api.cancelEdit()).toBe(false) + expect(onEditSettled).toHaveBeenCalledOnce() + }) + + it('retires the edit owner without restoring over a replacement transcript', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + ]) + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 0, + messageId: 'msg-A', + text: 'A', + })) + + options.messages.value = [ + { role: 'user', text: 'new owner', ts: null, messageId: 'msg-new' }, + ] + options.inputText.value = 'new owner draft' + + expect(api.cancelEdit()).toBe(true) + expect(api.editGeneration.value).toBe(2) + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(options.messages.value.map(message => message.text)).toEqual(['new owner']) + expect(options.inputText.value).toBe('new owner draft') + }) + + it('retires the edit owner when transcript items are replaced in place', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ]) + + api.editMessage(renderedMessage({ + role: 'user', + displayRole: 'user', + sourceIndex: 2, + messageId: 'msg-B', + text: 'B', + })) + const currentOwner = options.messages.value + currentOwner.splice(0, 1, { + role: 'user', text: 'new same-session row', ts: null, messageId: 'msg-new', + }) + options.inputText.value = 'new owner draft' + + expect(api.cancelEdit()).toBe(true) + expect(api.editGeneration.value).toBe(2) + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(options.messages.value).toBe(currentOwner) + expect(options.messages.value.map(message => message.text)).toEqual([ + 'new same-session row', 'ack A', + ]) + expect(options.inputText.value).toBe('new owner draft') + }) + + it('cancels nested edits one layer at a time without orphaning a fork', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + { role: 'assistant', text: 'ack B', ts: null, messageId: 'msg-b1' }, + ]) + options.inputText.value = 'unrelated original draft' + + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 2, messageId: 'msg-B', text: 'B', + })) + options.inputText.value = 'edited B draft' + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 0, messageId: 'msg-A', text: 'A', + })) + + expect(pendingForkBeforeMessageId.value).toBe('msg-A') + expect(api.cancelEdit()).toBe(true) + // The first Escape returns to the still-uncommitted B edit. + expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A']) + expect(options.inputText.value).toBe('edited B draft') + expect(pendingForkBeforeMessageId.value).toBe('msg-B') + + expect(api.cancelEdit()).toBe(true) + expect(options.messages.value.map(message => message.text)).toEqual([ + 'A', 'ack A', 'B', 'ack B', + ]) + expect(options.inputText.value).toBe('unrelated original draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(api.cancelEdit()).toBe(false) + }) + + it('keeps an active edit untouched when regenerate is requested', async () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + { role: 'assistant', text: 'ack B', ts: null, messageId: 'msg-b1' }, + ]) + const originalOwner = options.messages.value + + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 2, messageId: 'msg-B', text: 'B', + })) + options.inputText.value = 'edited B' + const editOwner = options.messages.value + + const regenerated = await api.regenerateMessage(renderedMessage({ + role: 'assistant', + displayRole: 'assistant', + sourceIndex: 1, + messageId: 'msg-a1', + text: 'ack A', + })) + await nextTick() + + expect(regenerated).toBe(false) + expect(options.sendCurrentInput).not.toHaveBeenCalled() + expect(options.messages.value).toBe(editOwner) + expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A']) + expect(options.inputText.value).toBe('edited B') + expect(pendingForkBeforeMessageId.value).toBe('msg-B') + + expect(api.cancelEdit()).toBe(true) + expect(options.messages.value).toBe(originalOwner) + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('retires a committed edit so regenerate is immediately available', async () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + { role: 'assistant', text: 'ack B', ts: null, messageId: 'msg-b1' }, + ]) + + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 2, messageId: 'msg-B', text: 'B', + })) + const generation = api.editGeneration.value + options.messages.value.push( + { role: 'user', text: 'edited B', ts: null, messageId: 'msg-B-edited' }, + { role: 'assistant', text: 'edited answer', ts: null, messageId: 'msg-b2' }, + ) + pendingForkBeforeMessageId.value = null + + expect(api.commitEdit(generation)).toBe(true) + expect(api.editActive.value).toBe(false) + expect(api.cancelEdit()).toBe(false) + expect(api.regenerateMessage(renderedMessage({ + role: 'assistant', + displayRole: 'assistant', + sourceIndex: 3, + messageId: 'msg-b2', + text: 'edited answer', + }))).toBe(true) + await nextTick() + + expect(pendingForkBeforeMessageId.value).toBe('msg-B-edited') + expect(options.sendCurrentInput).toHaveBeenCalledOnce() + }) + + it('does not replace a foreign pending fork when entering edit mode', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ]) + const forkOwner = options.messages.value + pendingForkBeforeMessageId.value = 'msg-A' + options.inputText.value = 'pending regenerate draft' + + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 2, messageId: 'msg-B', text: 'B', + })) + + expect(options.messages.value).toBe(forkOwner) + expect(options.inputText.value).toBe('pending regenerate draft') + expect(pendingForkBeforeMessageId.value).toBe('msg-A') + expect(options.focusComposer).not.toHaveBeenCalled() + expect(api.editGeneration.value).toBe(0) + expect(api.cancelEdit()).toBe(false) + }) + + it('retires an edit during pre-dispatch validation when history replaced it', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + ]) + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 0, messageId: 'msg-A', text: 'A', + })) + const generation = api.editGeneration.value + const replacement = [ + { role: 'user' as const, text: 'authoritative', ts: null, messageId: 'msg-new' }, + ] + options.messages.value = replacement + const replacementOwner = options.messages.value + options.inputText.value = 'authoritative draft' + + expect(api.validateEditOwner(generation)).toBe(false) + expect(api.editGeneration.value).toBe(generation + 1) + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(options.messages.value).toBe(replacementOwner) + expect(options.inputText.value).toBe('authoritative draft') + expect(api.cancelEdit()).toBe(false) + }) + + it('adopts only exact rejected-send rows before restoring the original edit state', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ]) + options.inputText.value = 'original draft' + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 2, messageId: 'msg-B', text: 'B', + })) + const generation = api.editGeneration.value + const optimistic: ChatMessage = { role: 'user', text: 'edited B', ts: null } + const error: ChatMessage = { role: 'error', text: 'rejected', ts: null } + options.messages.value.push(optimistic, error) + + expect(api.adoptRejectedEditRows(generation, [optimistic, error])).toBe(true) + expect(api.cancelEdit()).toBe(true) + expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A', 'B']) + expect(options.inputText.value).toBe('original draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('retires instead of adopting rejected rows around an unrelated suffix', () => { + const { api, options, pendingForkBeforeMessageId } = makeOptions([ + { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, + { role: 'assistant', text: 'ack A', ts: null, messageId: 'msg-a1' }, + { role: 'user', text: 'B', ts: null, messageId: 'msg-B' }, + ]) + api.editMessage(renderedMessage({ + role: 'user', displayRole: 'user', sourceIndex: 2, messageId: 'msg-B', text: 'B', + })) + const generation = api.editGeneration.value + const optimistic: ChatMessage = { role: 'user', text: 'edited B', ts: null } + const unrelated: ChatMessage = { role: 'assistant', text: 'authoritative row', ts: null } + const error: ChatMessage = { role: 'error', text: 'rejected', ts: null } + options.messages.value.push(optimistic, unrelated, error) + const currentOwner = options.messages.value + + expect(api.adoptRejectedEditRows(generation, [optimistic, error])).toBe(false) + expect(api.editGeneration.value).toBe(generation + 1) + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(api.cancelEdit()).toBe(false) + expect(options.messages.value).toBe(currentOwner) + expect(options.messages.value.map(message => message.text)).toEqual([ + 'A', 'ack A', 'edited B', 'authoritative row', 'rejected', + ]) + }) + it('records the previous user message id before regenerating', async () => { const { api, options, pendingForkBeforeMessageId } = makeOptions([ { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index 24df625e74..1fea35c461 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -1,4 +1,4 @@ -import { nextTick, type Ref } from 'vue' +import { nextTick, ref, toRaw, watch, type Ref } from 'vue' import type { ChatMessage, ChatRenderedMessage, @@ -15,6 +15,7 @@ import { sanitizeAssistantPresentationSegments } from '@/utils/chat/silentSentin import type { AssistantPresentationProvenance } from '@/utils/chat/silentSentinels' export interface UseChatMessageActionsOptions { + sessionKey: Ref messages: Ref inputText: Ref isStreaming: Ref @@ -47,9 +48,73 @@ export interface UseChatMessageActionsOptions { * points (keyboard, future surfaces) must not fail silently either. */ notifyEditBlocked?: () => void + /** Hold receipt/history reconciliation while an exact Edit snapshot is active. */ + onEditStarted?: () => void + /** Release deferred receipt/history reconciliation after Edit leaves ownership. */ + onEditSettled?: () => void +} + +interface EditRestorePoint { + /** Session owner; restore points never cross a session boundary. */ + sessionKey: string + /** The exact transcript array installed by this edit. */ + editingMessages: ChatMessage[] + /** Shallow item identities installed by this edit. */ + editingMessageOwners: ChatMessage[] + /** The transcript as it stood before edit truncated it. */ + messages: ChatMessage[] + /** Whatever the composer held before edit overwrote it with the message. */ + inputText: string + /** Fork owner that was active before this edit replaced it. */ + previousForkBeforeMessageId: string | null + /** Ties the restore point to the edit that made it; see `cancelEdit`. */ + forkBeforeMessageId: string + /** The edit that was active before this one, for layered Escape restores. */ + previousRestorePoint: EditRestorePoint | null } export function useChatMessageActions(options: UseChatMessageActionsOptions) { + let editRestorePoint: EditRestorePoint | null = null + const editGeneration = ref(0) + const editActive = ref(false) + + // Session transitions replace the transcript and composer domain. Retire the + // old restore point synchronously so even an immediate switch back cannot + // revive state captured before the boundary. + watch(options.sessionKey, () => { + const hadActiveEdit = editRestorePoint !== null + editRestorePoint = null + editActive.value = false + editGeneration.value += 1 + if (hadActiveEdit) options.onEditSettled?.() + }, { flush: 'sync' }) + + function restoreOwnsCurrentSessionAndFork(restore: EditRestorePoint): boolean { + return options.sessionKey.value === restore.sessionKey + && options.pendingForkBeforeMessageId.value === restore.forkBeforeMessageId + } + + function restoreOwnsCurrentTranscript(restore: EditRestorePoint): boolean { + const currentMessages = options.messages.value + return toRaw(currentMessages) === restore.editingMessages + && currentMessages.length === restore.editingMessageOwners.length + && currentMessages.every( + (message, index) => toRaw(message) === restore.editingMessageOwners[index], + ) + } + + function retireOwnedEdit(restore: EditRestorePoint): void { + editRestorePoint = null + editActive.value = false + editGeneration.value += 1 + if ( + options.sessionKey.value === restore.sessionKey + && options.pendingForkBeforeMessageId.value === restore.forkBeforeMessageId + ) { + options.pendingForkBeforeMessageId.value = null + } + } + function copyableMessageText(message: ChatRenderedMessage): string { // User bubbles render the raw text with only the time prefix stripped, so // copy must match: the markdown sanitizers would truncate or strip literal @@ -141,6 +206,13 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { console.warn('Wait for the current response to finish') return false } + if (editRestorePoint) { + // Regenerate and edit both replace the visible branch, but only edit has + // an Escape restore frame. Let the user cancel or send that edit first; + // otherwise regenerate would replace its fork and orphan the frame. + console.warn('Finish or cancel the current message edit before regenerating') + return false + } const usageBarrierRetry = isUsageAccountingBarrierMessage(message) const assistantIndex = sourceMessageIndex(message) const usageBarrierUserIndex = strictUsageBarrierRetryUserMessageIndex( @@ -205,16 +277,179 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { return } const text = sourceMessage.text || '' + const editingMessages = options.messages.value.slice(0, msgIndex) + const previousRestore = editRestorePoint + if ( + previousRestore + && ( + !restoreOwnsCurrentSessionAndFork(previousRestore) + || !restoreOwnsCurrentTranscript(previousRestore) + ) + ) { + // Never hang a new edit from a stale lower frame. In particular, an + // authoritative history replacement must not leave its old fork anchor + // underneath the new restore point, where a later Escape could revive it. + retireOwnedEdit(previousRestore) + } + if (!editRestorePoint && options.pendingForkBeforeMessageId.value) { + // A regenerate (or another branch owner) already owns this composer. + // Replacing its fork without a corresponding restore frame would make + // Escape unable to return to either operation coherently. + console.warn('Finish the current branched draft before editing another message') + return + } + const startsEditIsolation = editRestorePoint === null + editGeneration.value += 1 + // Everything below this line is undone by `cancelEdit`. Entering edit mode + // is not a decision the user has confirmed — the transcript shrinks to + // nothing on the first click, and until #1372 there was no way back: + // Escape cleared the composer and left the empty state on screen, which + // reads as the conversation having been deleted. + editRestorePoint = { + sessionKey: options.sessionKey.value, + editingMessages, + editingMessageOwners: editingMessages.map(message => toRaw(message)), + messages: options.messages.value, + inputText: options.inputText.value, + previousForkBeforeMessageId: options.pendingForkBeforeMessageId.value, + forkBeforeMessageId, + previousRestorePoint: editRestorePoint, + } + if (startsEditIsolation) { + editActive.value = true + options.onEditStarted?.() + } options.pendingForkBeforeMessageId.value = forkBeforeMessageId - options.messages.value = options.messages.value.slice(0, msgIndex) + options.messages.value = editingMessages options.inputText.value = text options.autoResizeTextarea() options.focusComposer() } + /** + * Put the transcript and the draft back, if an edit is still uncommitted. + * + * Returns whether Escape handled an edit, including retiring an edit whose + * transcript was authoritatively replaced. The latter must consume Escape so + * the replacement owner's draft is not cleared by the ordinary shortcut. + * + * The top restore point is only honoured while + * `pendingForkBeforeMessageId` still holds the id that edit set. Sending + * consumes that id and retires the whole stack; a nested edit instead becomes + * the new top and Escape returns one layer at a time. + */ + function cancelEdit(): boolean { + const restore = editRestorePoint + if (!restore) return false + if (!restoreOwnsCurrentSessionAndFork(restore)) { + // The fork was consumed or replaced by another action. Drop every lower + // frame without touching the new owner or resurrecting an older branch. + editRestorePoint = null + editActive.value = false + editGeneration.value += 1 + options.onEditSettled?.() + return false + } + if (!restoreOwnsCurrentTranscript(restore)) { + // A same-session history refresh can replace the array while an edit-owned + // send is awaiting preflight. The authoritative transcript must win, but + // the abandoned edit must not leave either that send generation or its + // fork anchor live for the next ordinary draft. + retireOwnedEdit(restore) + options.onEditSettled?.() + return true + } + editRestorePoint = restore.previousRestorePoint + editActive.value = editRestorePoint !== null + editGeneration.value += 1 + options.pendingForkBeforeMessageId.value = restore.previousForkBeforeMessageId + options.messages.value = restore.messages + options.inputText.value = restore.inputText + options.autoResizeTextarea() + if (!editRestorePoint) options.onEditSettled?.() + return true + } + + /** Retire the matching restore frame after Gateway acceptance commits it. */ + function commitEdit(generation: number): boolean { + if (editGeneration.value !== generation) return false + if (!editRestorePoint) return true + editRestorePoint = null + editActive.value = false + editGeneration.value += 1 + options.onEditSettled?.() + return true + } + + /** + * Revalidate the uncommitted edit immediately before a send mutates state. + * Generation alone cannot detect a same-session history refresh because it + * happens outside this composable. + */ + function validateEditOwner(generation: number): boolean { + if (editGeneration.value !== generation) return false + const restore = editRestorePoint + if (!restore) return true + if (!restoreOwnsCurrentSessionAndFork(restore)) { + editRestorePoint = null + editActive.value = false + editGeneration.value += 1 + options.onEditSettled?.() + return false + } + if (restoreOwnsCurrentTranscript(restore)) return true + retireOwnedEdit(restore) + options.onEditSettled?.() + return false + } + + /** + * A definitely rejected send may leave only its own optimistic/error rows in + * the edit-owned transcript. Adopt those exact identities so Escape can still + * restore the pre-edit conversation. Arbitrary suffixes are never accepted. + */ + function adoptRejectedEditRows( + generation: number, + rows: readonly ChatMessage[], + ): boolean { + if (editGeneration.value !== generation || rows.length === 0) return false + const restore = editRestorePoint + if (!restore) return false + if (!restoreOwnsCurrentSessionAndFork(restore)) { + editRestorePoint = null + editActive.value = false + editGeneration.value += 1 + options.onEditSettled?.() + return false + } + const currentMessages = options.messages.value + const expectedLength = restore.editingMessageOwners.length + rows.length + const ownsPrefix = toRaw(currentMessages) === restore.editingMessages + && currentMessages.length === expectedLength + && restore.editingMessageOwners.every( + (message, index) => toRaw(currentMessages[index]) === message, + ) + const ownsSuffix = rows.every((message, index) => ( + toRaw(currentMessages[restore.editingMessageOwners.length + index]) === toRaw(message) + )) + if (!ownsPrefix || !ownsSuffix) { + retireOwnedEdit(restore) + options.onEditSettled?.() + return false + } + restore.editingMessageOwners.push(...rows.map(message => toRaw(message))) + return true + } + return { copyMessage, regenerateMessage, editMessage, + cancelEdit, + commitEdit, + validateEditOwner, + adoptRejectedEditRows, + editGeneration, + editActive, } } diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 853944a809..e375bfb576 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -1849,6 +1849,63 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('flushes a deferred terminal drain when its released WAL row hydrates later', async () => { + vi.useFakeTimers() + const sessionKey = 'agent:main:webchat:test' + const record: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId: 'pending-late-hydrate-drain', + sessionKey, + clientRequestId: 'request-late-hydrate-drain', + clientMessageId: 'message-late-hydrate-drain', + text: 'dispatch after hydrate', + attachments: [], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const { wal } = memoryWal([record]) + const baseList = wal.list + let releaseHydrate!: () => void + wal.list = vi.fn(async key => { + await new Promise(resolve => { + releaseHydrate = resolve + }) + return baseList(key) + }) + let blocked = true + const dispatchPendingItem = vi.fn(async () => 'accepted' as const) + const harness = makeQueue( + dispatchPendingItem, + () => blocked, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + try { + await vi.waitFor(() => expect(releaseHydrate).toBeTypeOf('function')) + expect(harness.queue.pendingQueue.value).toEqual([]) + harness.queue.schedulePendingDrainAfterTerminal() + + blocked = false + releaseHydrate() + await vi.waitFor(() => expect(harness.queue.pendingQueue.value).toHaveLength(1)) + await vi.advanceTimersByTimeAsync(50) + await nextTick() + + expect(dispatchPendingItem).toHaveBeenCalledWith( + expect.objectContaining({ pendingInputId: record.pendingInputId }), + sessionKey, + ) + } finally { + harness.queue.cleanup() + vi.useRealTimers() + } + }) + it('drains an image queue item exactly once after the live capability unblocks', async () => { vi.useFakeTimers() let blocked = true diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index bb218e55d0..a35d0dcb98 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -661,6 +661,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { return } mergeWalRecords(records, sessionKey) + // A terminal receipt may have been recorded before its released owner rows + // became visible. Hydration is the final boundary that can make them + // drainable, so re-check the deferred signal after merging the WAL. + flushDeferredPendingDrain() const walIds = new Set(records.map(record => record.pendingInputId)) if (!supportsServerQueue()) { @@ -1411,10 +1415,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { sourceSessionKey: string, targetSessionKey: string, ownerRequestId: string, - ): Promise { - if (!sourceSessionKey || !targetSessionKey || !ownerRequestId) return + ): Promise { + if (!sourceSessionKey || !targetSessionKey || !ownerRequestId) return false const committed = await acceptDurableHandoff(targetSessionKey, ownerRequestId) - if (!committed) return + if (!committed) return false if (options.sessionKey.value === targetSessionKey) { const restored = parkedQueues.get(targetSessionKey) || [] parkedQueues.delete(targetSessionKey) @@ -1426,6 +1430,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } broadcastChange(sourceSessionKey) broadcastChange(targetSessionKey) + return true } async function failPendingQueueHandoff(ownerRequestId: string): Promise { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 3636591587..bc509df1ef 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { effectScope, nextTick, ref } from 'vue' import { useChatRpcEventHandlers, type ChatRpcStreamApi } from './useChatRpcEventHandlers' +import { useChatMessageActions } from './useChatMessageActions' +import { useChatTaskOwnership } from './useChatTaskOwnership' import type { SessionBootstrapRun } from './useChatSessionBootstrap' import type { ChatMessage, @@ -31,12 +33,14 @@ function createHarness(options: { handleSessionConnectionState?: (state: string) => SessionBootstrapRun | undefined loadCurrentSessionUsage?: () => void refreshRunModePreference?: () => void | Promise + normalizeRunStatus?: (status: string) => string pendingQueue?: ChatPendingItem[] stream?: ChatRpcStreamApi restoreSteerIntoComposer?: (text: string) => void getCompactionPlacement?: (compactionId: string) => 'activity' | 'standalone' | undefined observeStreamGeneration?: (payload: unknown) => boolean supportsTurnCommitted?: boolean + taskOwnership?: ReturnType } = {}) { const messages = ref(options.messages ?? []) const sessionKey = ref('agent:main:test') @@ -78,6 +82,7 @@ function createHarness(options: { const markEnsembleHandoff = vi.fn() const bindRouterDecisionToModelCall = vi.fn() const queueRouterDecision = vi.fn() + const clearPendingRouterDecision = vi.fn() const schedulePendingDrainAfterTerminal = vi.fn() const scheduleHistorySync = vi.fn() const showCompactionToast = vi.fn() @@ -112,7 +117,7 @@ function createHarness(options: { }), usageModel: ref(''), stream, - normalizeRunStatus: (status: string) => status, + normalizeRunStatus: options.normalizeRunStatus || ((status: string) => status), sessionRunStatus: options.sessionRunStatus || (() => ({ status: 'idle', label: 'Idle', task: null })), applySessionRunState, queueRouterDecision, @@ -120,7 +125,7 @@ function createHarness(options: { appendEnsembleProgress: vi.fn(), markEnsembleHandoff, flushPendingRouterDecision: vi.fn(), - clearPendingRouterDecision: vi.fn(), + clearPendingRouterDecision, handleRouterControlReplay: vi.fn(), showCompactionToast, getCompactionPlacement: options.getCompactionPlacement, @@ -137,6 +142,7 @@ function createHarness(options: { handleSessionConnectionState, loadCurrentSessionUsage, refreshRunModePreference, + taskOwnership: options.taskOwnership, }))! const api = { ...rawApi, @@ -162,6 +168,7 @@ function createHarness(options: { markEnsembleHandoff, bindRouterDecisionToModelCall, queueRouterDecision, + clearPendingRouterDecision, schedulePendingDrainAfterTerminal, scheduleHistorySync, showCompactionToast, @@ -259,6 +266,649 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { harness.stop() } }) + + it('defers an early terminal receipt until its same-session ACK allows projection', () => { + const taskOwnership = useChatTaskOwnership() + const harness = createHarness({ + taskOwnership, + pendingQueue: [{ + pendingUiId: 'pending-after-receipt', + text: 'send after receipt', + attachments: [], + intent: null, + ownerSessionKey: 'agent:main:test', + }], + messages: [{ role: 'assistant', text: 'current history', ts: null }], + }) + harness.stream.isStreaming.value = false + const historyOwner = harness.messages.value + const payload = { + session_key: 'agent:main:test', + task_id: 'task-early-terminal', + client_message_id: 'client-early-terminal', + } + try { + harness.api.beginBackgroundReceiptReplay('client-early-terminal') + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent('task.succeeded', payload, {}), + payload, + meta: {}, + }) + + expect(harness.applySessionRunState).not.toHaveBeenCalled() + expect(harness.scheduleHistorySync).not.toHaveBeenCalled() + expect(harness.schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + expect(taskOwnership.hasAuthoritativeWork.value).toBe(false) + expect(harness.messages.value).toBe(historyOwner) + + harness.api.trackBackgroundReceiptTask( + 'client-early-terminal', + 'task-early-terminal', + false, + true, + ) + + expect(harness.applySessionRunState).toHaveBeenCalledWith({ + run_status: 'idle', + last_task: expect.objectContaining({ + task_id: 'task-early-terminal', + status: 'succeeded', + }), + }) + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + expect(harness.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + expect(taskOwnership.hasAuthoritativeWork.value).toBe(false) + expect(harness.messages.value).toBe(historyOwner) + } finally { + harness.stop() + } + }) + + it('keeps a rich early terminal snapshot when a sparse echo arrives before ACK', () => { + const taskOwnership = useChatTaskOwnership() + const harness = createHarness({ + taskOwnership, + pendingQueue: [{ + pendingUiId: 'pending-after-successor', + text: 'wait for successor', + attachments: [], + intent: null, + }], + }) + harness.stream.isStreaming.value = false + try { + harness.api.beginBackgroundReceiptReplay('client-rich-terminal') + harness.api.onConversationEvent({ + kind: 'sessions-changed', + payload: { + session_key: 'agent:main:test', + reason: 'task_terminal', + run_status: 'running', + changed_task: { + task_id: 'task-rich-terminal', + client_message_id: 'client-rich-terminal', + status: 'succeeded', + }, + last_task: { + task_id: 'task-rich-terminal', + client_message_id: 'client-rich-terminal', + status: 'succeeded', + }, + active_task: { + task_id: 'task-rich-successor', + client_message_id: 'client-rich-successor', + status: 'running', + }, + }, + meta: {}, + }) + const sparsePayload = { + session_key: 'agent:main:test', + task_id: 'task-rich-terminal', + client_message_id: 'client-rich-terminal', + } + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent('task.succeeded', sparsePayload, {}), + payload: sparsePayload, + meta: {}, + }) + + expect(harness.applySessionRunState).not.toHaveBeenCalled() + expect(harness.scheduleHistorySync).not.toHaveBeenCalled() + harness.api.trackBackgroundReceiptTask( + 'client-rich-terminal', + 'task-rich-terminal', + false, + true, + ) + + expect(taskOwnership.runningTaskId.value).toBe('task-rich-successor') + expect(harness.applySessionRunState).toHaveBeenLastCalledWith(expect.objectContaining({ + run_status: 'running', + active_task: expect.objectContaining({ task_id: 'task-rich-successor' }), + })) + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + expect(harness.schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + } finally { + harness.stop() + } + }) + + it('quarantines live and terminal events from a background receipt replay', () => { + const harness = createHarness({ + messages: [ + { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, + { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, + ], + }) + harness.stream.isStreaming.value = false + const originalOwner = harness.messages.value + const inputText = ref('unrelated draft') + const pendingForkBeforeMessageId = ref(null) + const pendingAttachments = ref([{ + kind: 'staged' as const, + local_id: 901, + name: 'new-edit.png', + mime: 'image/png', + file_uuid: 'new-edit-file', + }]) + const promptAnnotationIds = ref(['current-edit-annotation']) + const pendingSessionIntent = ref('new_chat') + const messageActions = useChatMessageActions({ + sessionKey: harness.sessionKey, + messages: harness.messages, + inputText, + isStreaming: harness.stream.isStreaming, + sanitizeCopyText: text => text, + stripTimePrefix: text => text, + autoResizeTextarea: vi.fn(), + sendCurrentInput: vi.fn(), + sendUsageBarrierReplay: vi.fn(async () => true), + focusComposer: vi.fn(), + pendingForkBeforeMessageId, + onEditStarted: harness.api.holdBackgroundReceiptReconciliation, + onEditSettled: harness.api.releaseBackgroundReceiptReconciliation, + }) + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'original question', + timeStr: '', + showHeader: false, + sourceIndex: 0, + messageId: 'msg-original', + }) + inputText.value = 'edited original question' + const editOwner = harness.messages.value + const attachmentOwner = pendingAttachments.value[0] + const deliver = (eventName: string, payload: Record) => { + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent(eventName, payload, {}), + payload, + meta: {}, + }) + } + try { + harness.api.beginBackgroundReceiptReplay('client-old-receipt') + deliver('task.queued', { + session_key: 'agent:main:test', + task_id: 'task-other-tab', + client_message_id: 'client-other-tab', + }) + // A second tab's task is not owned merely because it interleaves with + // the receipt RPC window. + expect(harness.applySessionRunState).toHaveBeenCalledOnce() + harness.applySessionRunState.mockClear() + + deliver('task.running', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + }) + harness.api.trackBackgroundReceiptTask('client-old-receipt', 'task-old-receipt') + harness.api.finishBackgroundReceiptReplay('client-old-receipt') + deliver('session.event.text_delta', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + stream_seq: 1, + text: 'old answer', + }) + deliver('session.event.done', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + stream_seq: 2, + text: 'old terminal answer', + }) + harness.api.onConversationEvent({ + kind: 'sessions-changed', + payload: { + session_key: 'agent:main:test', + reason: 'task_terminal', + run_status: 'running', + changed_task: { + task_id: 'task-old-receipt', + status: 'succeeded', + }, + last_task: { + task_id: 'task-old-receipt', + status: 'succeeded', + }, + active_task: { + task_id: 'task-successor', + client_message_id: 'client-successor', + status: 'running', + }, + }, + meta: {}, + }) + deliver('task.succeeded', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + stream_seq: 3, + }) + deliver('session.event.turn_committed', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + stream_seq: 4, + }) + harness.api.onConversationEvent({ + kind: 'sessions-changed', + payload: { + session_key: 'agent:main:test', + reason: 'turn_complete', + turn_id: 'turn-old-direct', + client_message_id: 'client-old-receipt', + status: 'done', + }, + meta: {}, + }) + + expect(harness.activeStreamTaskId.value).toBe('') + expect(harness.stream.startStreaming).not.toHaveBeenCalled() + expect(harness.stream.appendDelta).not.toHaveBeenCalled() + expect(harness.stream.endStreaming).not.toHaveBeenCalled() + expect(harness.applySessionRunState).toHaveBeenCalledTimes(3) + expect(harness.applySessionRunState).toHaveBeenCalledWith({ + run_status: 'running', + active_task: { + task_id: 'task-old-receipt', + status: 'running', + }, + }) + expect(harness.applySessionRunState).toHaveBeenCalledWith(expect.objectContaining({ + run_status: 'running', + active_task: expect.objectContaining({ task_id: 'task-successor' }), + })) + expect(harness.messages.value).toBe(editOwner) + expect(harness.messages.value).toEqual([]) + expect(harness.scheduleHistorySync).not.toHaveBeenCalled() + expect(inputText.value).toBe('edited original question') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(pendingAttachments.value).toEqual([attachmentOwner]) + expect(promptAnnotationIds.value).toEqual(['current-edit-annotation']) + expect(pendingSessionIntent.value).toBe('new_chat') + + expect(messageActions.cancelEdit()).toBe(true) + expect(harness.messages.value).toBe(originalOwner) + expect(harness.messages.value.map(message => message.text)).toEqual([ + 'original question', 'original answer', + ]) + expect(inputText.value).toBe('unrelated draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + } finally { + harness.stop() + } + }) + + it('drops deferred receipt reconciliation when Edit crosses into a new session', () => { + const harness = createHarness({ + messages: [ + { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, + { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, + ], + }) + harness.stream.isStreaming.value = false + const messageActions = useChatMessageActions({ + sessionKey: harness.sessionKey, + messages: harness.messages, + inputText: ref(''), + isStreaming: harness.stream.isStreaming, + sanitizeCopyText: text => text, + stripTimePrefix: text => text, + autoResizeTextarea: vi.fn(), + sendCurrentInput: vi.fn(), + sendUsageBarrierReplay: vi.fn(async () => true), + focusComposer: vi.fn(), + pendingForkBeforeMessageId: ref(null), + onEditStarted: harness.api.holdBackgroundReceiptReconciliation, + onEditSettled: harness.api.releaseBackgroundReceiptReconciliation, + }) + const deliver = (eventName: string, payload: Record) => { + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent(eventName, payload, {}), + payload, + meta: {}, + }) + } + try { + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'original question', + timeStr: '', + showHeader: false, + sourceIndex: 0, + messageId: 'msg-original', + }) + harness.api.beginBackgroundReceiptReplay('client-old-receipt') + deliver('task.running', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + }) + harness.api.trackBackgroundReceiptTask('client-old-receipt', 'task-old-receipt') + harness.api.finishBackgroundReceiptReplay('client-old-receipt') + deliver('task.succeeded', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + }) + expect(harness.scheduleHistorySync).not.toHaveBeenCalled() + + harness.sessionKey.value = 'agent:main:new-draft' + expect(messageActions.editActive.value).toBe(false) + expect(harness.scheduleHistorySync).not.toHaveBeenCalled() + + harness.api.beginBackgroundReceiptReplay('client-new-receipt') + deliver('task.running', { + session_key: 'agent:main:new-draft', + task_id: 'task-new-receipt', + client_message_id: 'client-new-receipt', + }) + harness.api.trackBackgroundReceiptTask('client-new-receipt', 'task-new-receipt') + harness.api.finishBackgroundReceiptReplay('client-new-receipt') + deliver('task.succeeded', { + session_key: 'agent:main:new-draft', + task_id: 'task-new-receipt', + }) + + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + } finally { + harness.stop() + } + }) + + it('settles run state and drains queued work once for a background receipt terminal', () => { + const taskOwnership = useChatTaskOwnership() + const harness = createHarness({ + taskOwnership, + pendingQueue: [{ + pendingUiId: 'pending-after-receipt', + text: 'send after the old receipt settles', + attachments: [], + intent: null, + }], + }) + harness.stream.isStreaming.value = false + const deliver = (eventName: string, payload: Record) => { + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent(eventName, payload, {}), + payload, + meta: {}, + }) + } + try { + harness.api.beginBackgroundReceiptReplay('client-old-receipt') + deliver('task.running', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + }) + expect(taskOwnership.hasAuthoritativeWork.value).toBe(false) + + harness.api.trackBackgroundReceiptTask( + 'client-old-receipt', + 'task-old-receipt', + 'succeeded', + ) + + deliver('task.succeeded', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + status: 'succeeded', + }) + + expect(taskOwnership.hasAuthoritativeWork.value).toBe(false) + expect(harness.applySessionRunState).toHaveBeenLastCalledWith(expect.objectContaining({ + run_status: 'idle', + last_task: expect.objectContaining({ + task_id: 'task-old-receipt', + status: 'succeeded', + }), + })) + expect(harness.clearPendingRouterDecision).toHaveBeenCalledOnce() + expect(harness.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + + deliver('session.event.turn_committed', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + }) + expect(harness.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + } finally { + harness.stop() + } + }) + + it('does not let an old receipt terminal overwrite a newer pending foreground stream', () => { + const harness = createHarness({ + pendingQueue: [{ + pendingUiId: 'pending-after-foreground', + text: 'wait for the foreground turn', + attachments: [], + intent: null, + }], + }) + harness.stream.isStreaming.value = true + harness.activeStreamTaskId.value = PENDING_STREAM_TASK_ID + try { + harness.api.beginBackgroundReceiptReplay('client-old-receipt') + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent('task.succeeded', { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + }, {}), + payload: { + session_key: 'agent:main:test', + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + }, + meta: {}, + }) + harness.api.trackBackgroundReceiptTask( + 'client-old-receipt', + 'task-old-receipt', + false, + true, + ) + + expect(harness.applySessionRunState).not.toHaveBeenCalled() + expect(harness.clearPendingRouterDecision).not.toHaveBeenCalled() + expect(harness.schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + } finally { + harness.stop() + } + }) + + it.each([ + ['task.cancelled', 'cancelled', 'cancelled', false], + ['task.timeout', 'timeout', 'failed', true], + ['task.abandoned', 'abandoned', 'failed', true], + ] as const)( + 'derives a missing receipt status from %s', + (eventName, expectedTaskStatus, expectedRunStatus, shouldDrain) => { + const harness = createHarness({ + pendingQueue: [{ + pendingUiId: 'pending-after-terminal', + text: 'continue after terminal', + attachments: [], + intent: null, + }], + }) + harness.stream.isStreaming.value = false + try { + harness.api.beginBackgroundReceiptReplay('client-terminal-kind') + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent(eventName, { + session_key: 'agent:main:test', + task_id: 'task-terminal-kind', + client_message_id: 'client-terminal-kind', + }, {}), + payload: { + session_key: 'agent:main:test', + task_id: 'task-terminal-kind', + client_message_id: 'client-terminal-kind', + }, + meta: {}, + }) + harness.api.trackBackgroundReceiptTask( + 'client-terminal-kind', + 'task-terminal-kind', + false, + true, + ) + + expect(harness.applySessionRunState).toHaveBeenCalledWith(expect.objectContaining({ + run_status: expectedRunStatus, + last_task: expect.objectContaining({ status: expectedTaskStatus }), + })) + if (shouldDrain) { + expect(harness.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + } else { + expect(harness.schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + } + } finally { + harness.stop() + } + }, + ) + + it('normalizes a killed receipt echo as cancelled without draining queued work', () => { + const harness = createHarness({ + normalizeRunStatus: status => status === 'killed' ? 'cancelled' : status, + pendingQueue: [{ + pendingUiId: 'pending-after-killed', + text: 'do not drain after cancellation', + attachments: [], + intent: null, + }], + }) + harness.stream.isStreaming.value = false + try { + harness.api.beginBackgroundReceiptReplay('client-killed-receipt') + harness.api.onConversationEvent({ + kind: 'sessions-changed', + payload: { + session_key: 'agent:main:test', + reason: 'task_terminal', + run_status: 'killed', + changed_task: { + task_id: 'task-killed-receipt', + client_message_id: 'client-killed-receipt', + status: 'killed', + }, + last_task: { + task_id: 'task-killed-receipt', + client_message_id: 'client-killed-receipt', + status: 'killed', + }, + }, + meta: {}, + }) + harness.api.trackBackgroundReceiptTask( + 'client-killed-receipt', + 'task-killed-receipt', + false, + true, + ) + + expect(harness.applySessionRunState).toHaveBeenCalledWith(expect.objectContaining({ + run_status: 'cancelled', + last_task: expect.objectContaining({ status: 'cancelled' }), + })) + expect(harness.schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + } finally { + harness.stop() + } + }) + + it('does not learn receipt task ownership from a stale subscription epoch', () => { + const harness = createHarness() + const deliver = (eventName: string, payload: Record) => { + harness.api.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent(eventName, payload, {}), + payload, + meta: {}, + }) + } + try { + harness.api.beginBackgroundReceiptReplay('client-old-epoch') + deliver('task.running', { + session_key: 'agent:main:test', + epoch: -1, + task_id: 'task-old-epoch', + client_message_id: 'client-old-epoch', + }) + harness.api.finishBackgroundReceiptReplay('client-old-epoch') + + deliver('session.event.text_delta', { + session_key: 'agent:main:test', + task_id: 'task-old-epoch', + stream_seq: 1, + text: 'current-epoch answer', + }) + + expect(harness.stream.appendDelta).toHaveBeenCalledWith('current-epoch answer') + } finally { + harness.stop() + } + }) + + it('does not re-arm reconciliation when the same receipt is registered again', () => { + const harness = createHarness() + try { + harness.api.beginBackgroundReceiptReplay('client-reconciled') + harness.api.trackBackgroundReceiptTask( + 'client-reconciled', + 'task-reconciled', + true, + ) + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + + harness.api.beginBackgroundReceiptReplay('client-reconciled') + harness.api.trackBackgroundReceiptTask( + 'client-reconciled', + 'task-reconciled', + true, + ) + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() + } finally { + harness.stop() + } + }) }) describe('useChatRpcEventHandlers live snapshot restoration', () => { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index efbe2b4f81..5a40e16868 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -412,6 +412,427 @@ interface TurnActivityRecord { } export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) { + interface BackgroundReceiptTask { + clientMessageId: string + terminalSeen: boolean + allowProjection: boolean + lifecycleStatus: string + } + + interface DeferredBackgroundReceiptTerminal { + clientMessageId: string + eventKind: ConversationSemanticEventKind | 'sessions-changed' + payload: SessionEventPayload + status: string + terminalTask: object + priority: number + } + + const pendingBackgroundReceiptClientIds = new Set() + const backgroundReceiptClientIds = new Set() + const backgroundReceiptTasks = new Map() + const deferredBackgroundReceiptTerminals = new Map< + string, + DeferredBackgroundReceiptTerminal + >() + const dirtyBackgroundReceiptClientIds = new Set() + const reconciledBackgroundReceiptClientIds = new Set() + const settledBackgroundReceiptClientIds = new Set() + let backgroundReceiptEditHeld = false + let backgroundReceiptHoldSessionKey = '' + + function rememberBackgroundReceiptClient(clientMessageId: string) { + const normalizedClientId = String(clientMessageId || '').trim() + if (!normalizedClientId) return + if (!backgroundReceiptClientIds.has(normalizedClientId) && backgroundReceiptClientIds.size >= 256) { + const oldestClientId = backgroundReceiptClientIds.values().next().value + if (typeof oldestClientId === 'string') { + backgroundReceiptClientIds.delete(oldestClientId) + dirtyBackgroundReceiptClientIds.delete(oldestClientId) + reconciledBackgroundReceiptClientIds.delete(oldestClientId) + settledBackgroundReceiptClientIds.delete(oldestClientId) + } + } + backgroundReceiptClientIds.add(normalizedClientId) + } + + function holdBackgroundReceiptReconciliation() { + if (!backgroundReceiptEditHeld) backgroundReceiptHoldSessionKey = sessionKey.value + backgroundReceiptEditHeld = true + } + + function flushBackgroundReceiptReconciliationIfReady() { + if (backgroundReceiptEditHeld || dirtyBackgroundReceiptClientIds.size === 0) return + for (const clientMessageId of dirtyBackgroundReceiptClientIds) { + reconciledBackgroundReceiptClientIds.add(clientMessageId) + } + dirtyBackgroundReceiptClientIds.clear() + options.scheduleHistorySync() + } + + function releaseBackgroundReceiptReconciliation() { + backgroundReceiptEditHeld = false + if (backgroundReceiptHoldSessionKey !== sessionKey.value) { + dirtyBackgroundReceiptClientIds.clear() + backgroundReceiptHoldSessionKey = '' + return + } + backgroundReceiptHoldSessionKey = '' + flushBackgroundReceiptReconciliationIfReady() + } + + function beginBackgroundReceiptReplay(clientMessageId: string, holdHistory = false) { + const normalizedClientId = String(clientMessageId || '').trim() + if (!normalizedClientId) return + const isNewReceipt = !backgroundReceiptClientIds.has(normalizedClientId) + pendingBackgroundReceiptClientIds.add(normalizedClientId) + rememberBackgroundReceiptClient(normalizedClientId) + if (isNewReceipt) reconciledBackgroundReceiptClientIds.delete(normalizedClientId) + if (holdHistory) holdBackgroundReceiptReconciliation() + } + + function rememberBackgroundReceiptTask( + clientMessageId: string, + taskId: string, + terminalSeen = false, + allowProjection?: boolean, + lifecycleStatus = '', + ) { + const normalizedClientId = String(clientMessageId || '').trim() + const normalizedTaskId = String(taskId || '').trim() + if (!normalizedClientId || !normalizedTaskId) return + rememberBackgroundReceiptClient(normalizedClientId) + const existing = backgroundReceiptTasks.get(normalizedTaskId) + if (!existing && backgroundReceiptTasks.size >= 256) { + const oldestTaskId = backgroundReceiptTasks.keys().next().value + if (typeof oldestTaskId === 'string') { + backgroundReceiptTasks.delete(oldestTaskId) + deferredBackgroundReceiptTerminals.delete(oldestTaskId) + } + } + backgroundReceiptTasks.set(normalizedTaskId, { + clientMessageId: normalizedClientId, + terminalSeen: terminalSeen || existing?.terminalSeen === true, + allowProjection: allowProjection ?? existing?.allowProjection ?? false, + lifecycleStatus: ( + ['running', 'approval_pending'].includes(existing?.lifecycleStatus || '') + && !['running', 'approval_pending'].includes(lifecycleStatus) + ) + ? existing!.lifecycleStatus + : lifecycleStatus || existing?.lifecycleStatus || '', + }) + } + + function trackBackgroundReceiptTask( + clientMessageId: string, + taskId: string, + terminal: boolean | string = false, + allowProjection = true, + retireParentProjection = false, + acceptedStatus = '', + ) { + const normalizedClientId = String(clientMessageId || '').trim() + const normalizedTaskId = String(taskId || '').trim() + const terminalStatus = typeof terminal === 'string' + ? terminal.trim().toLowerCase() + : terminal ? 'succeeded' : '' + rememberBackgroundReceiptClient(normalizedClientId) + rememberBackgroundReceiptTask( + normalizedClientId, + normalizedTaskId, + Boolean(terminalStatus), + allowProjection, + ) + const deferredCandidate = normalizedTaskId + ? deferredBackgroundReceiptTerminals.get(normalizedTaskId) + : undefined + const deferredTerminal = deferredCandidate?.clientMessageId === normalizedClientId + ? deferredCandidate + : undefined + if (!allowProjection) { + if (normalizedTaskId && (terminalStatus || retireParentProjection)) { + options.taskOwnership?.noteTerminal(normalizedTaskId, false) + } + deferredBackgroundReceiptTerminals.delete(normalizedTaskId) + if (terminalStatus) { + settleBackgroundReceiptTerminal( + normalizedClientId, + normalizedTaskId, + terminalStatus, + {}, + false, + ) + } + return + } + if (deferredTerminal) { + deferredBackgroundReceiptTerminals.delete(normalizedTaskId) + options.taskOwnership?.noteTerminal(normalizedTaskId) + markTaskSettled(deferredTerminal.payload) + if (!reconciledBackgroundReceiptClientIds.has(normalizedClientId)) { + dirtyBackgroundReceiptClientIds.add(normalizedClientId) + flushBackgroundReceiptReconciliationIfReady() + } + const hasContinuation = deferredTerminal.eventKind === 'sessions-changed' + && applyBackgroundReceiptContinuation(deferredTerminal.payload, normalizedTaskId) + settleBackgroundReceiptTerminal( + normalizedClientId, + normalizedTaskId, + deferredTerminal.status, + deferredTerminal.terminalTask, + !hasContinuation, + ) + return + } + if (terminalStatus) { + deferredBackgroundReceiptTerminals.delete(normalizedTaskId) + options.taskOwnership?.noteTerminal(normalizedTaskId) + if (!reconciledBackgroundReceiptClientIds.has(normalizedClientId)) { + dirtyBackgroundReceiptClientIds.add(normalizedClientId) + flushBackgroundReceiptReconciliationIfReady() + } + settleBackgroundReceiptTerminal( + normalizedClientId, + normalizedTaskId, + terminalStatus, + {}, + true, + ) + return + } + if (normalizedTaskId) { + const cachedStatus = backgroundReceiptTasks.get(normalizedTaskId)?.lifecycleStatus || '' + const lifecycleStatus = ( + ['running', 'approval_pending'].includes(cachedStatus) + ? cachedStatus + : acceptedStatus || cachedStatus || 'queued' + ) + options.taskOwnership?.noteAccepted(normalizedTaskId, lifecycleStatus) + if (lifecycleStatus === 'queued') { + options.applySessionRunState({ + run_status: 'queued', + active_task: { task_id: normalizedTaskId, status: 'queued' }, + }) + } else if (['running', 'approval_pending'].includes(lifecycleStatus)) { + options.applySessionRunState({ + run_status: lifecycleStatus, + active_task: { task_id: normalizedTaskId, status: lifecycleStatus }, + }) + } + } + } + + function settleBackgroundReceiptTerminal( + clientMessageId: string, + taskId: string, + rawStatus: string, + terminalTask: object, + allowProjection: boolean, + ) { + if (!clientMessageId || settledBackgroundReceiptClientIds.has(clientMessageId)) return + settledBackgroundReceiptClientIds.add(clientMessageId) + // The receipt owns its history/task cleanup, but never the visible run + // projection while a newer foreground send still owns the stream. + if ( + !allowProjection + || stream.isStreaming.value + || activeStreamTaskId.value === PENDING_STREAM_TASK_ID + || activeTaskGroups.value.size > 0 + || options.taskOwnership?.hasAuthoritativeWork.value + ) return + const normalizedStatus = options.normalizeRunStatus(rawStatus) + const failed = ['failed', 'timeout', 'abandoned'].includes(normalizedStatus) + const interrupted = ['cancelled', 'interrupted'].includes(normalizedStatus) + clearLiveThinking() + options.clearPendingRouterDecision() + options.applySessionRunState({ + run_status: failed ? 'failed' : interrupted ? 'cancelled' : 'idle', + last_task: { + ...terminalTask, + ...(taskId ? { task_id: taskId } : {}), + status: normalizedStatus || (failed ? 'failed' : 'succeeded'), + }, + }) + if (pendingQueue.value.length > 0 && !interrupted) { + options.schedulePendingDrainAfterTerminal() + } + } + + function finishBackgroundReceiptReplay(clientMessageId: string) { + pendingBackgroundReceiptClientIds.delete(String(clientMessageId || '').trim()) + } + + function receiptEventIdentities(payload: SessionEventPayload): Array<{ + clientMessageId: string + taskId: string + }> { + const candidates = [ + payload, + payload.changed_task, + payload.changedTask, + payload.last_task, + payload.lastTask, + payload.active_task, + payload.activeTask, + ] + const identities: Array<{ clientMessageId: string, taskId: string }> = [] + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') continue + const record = candidate as Record + const clientMessageId = String( + record.client_message_id || record.clientMessageId || '', + ).trim() + const taskId = String( + record.task_id || record.taskId || record.turn_id || record.turnId || '', + ).trim() + if (!taskId) continue + if (!identities.some(identity => ( + identity.taskId === taskId && identity.clientMessageId === clientMessageId + ))) identities.push({ clientMessageId, taskId }) + } + return identities + } + + function matchingBackgroundReceiptIdentity(payload: SessionEventPayload): { + clientMessageId: string + taskId: string + allowProjection: boolean + } | null { + for (const identity of receiptEventIdentities(payload)) { + const tracked = backgroundReceiptTasks.get(identity.taskId) + if (tracked) { + return { + clientMessageId: tracked.clientMessageId, + taskId: identity.taskId, + allowProjection: tracked.allowProjection, + } + } + if ( + identity.clientMessageId + && backgroundReceiptClientIds.has(identity.clientMessageId) + ) return { ...identity, allowProjection: false } + } + return null + } + + function applyBackgroundReceiptContinuation( + payload: SessionEventPayload, + receiptTaskId: string, + ): boolean { + const activeTask = (payload.active_task || payload.activeTask) as Record | undefined + const activeTaskId = activeTask + ? String(activeTask.task_id || activeTask.taskId || activeTask.turn_id || activeTask.turnId || '').trim() + : '' + if (!activeTask || !activeTaskId || activeTaskId === receiptTaskId) return false + const activeStatus = String(activeTask?.status || '').trim().toLowerCase() + if (activeStatus === 'queued') options.taskOwnership?.noteQueued(activeTask) + else options.taskOwnership?.noteRunning({ ...activeTask, status: activeStatus || 'running' }) + const continuation: SessionEventPayload = { + ...payload, + reason: 'background_receipt_continuation', + run_status: activeStatus || 'running', + } + delete continuation.changed_task + delete continuation.changedTask + delete continuation.last_task + delete continuation.lastTask + delete continuation.status + handleRpcSessionsChanged(continuation) + return true + } + + function deferredBackgroundReceiptTerminalPriority( + eventKind: ConversationSemanticEventKind | 'sessions-changed', + payload: SessionEventPayload, + ): number { + if (eventKind !== 'sessions-changed') return 1 + const activeTask = payload.active_task || payload.activeTask + return activeTask && typeof activeTask === 'object' ? 3 : 2 + } + + function suppressBackgroundReceiptEvent( + eventKind: ConversationSemanticEventKind | 'sessions-changed', + payload: SessionEventPayload, + ): boolean { + if (isStaleEpoch(payload)) return false + if (!isCurrentSessionPayload(payload)) return false + const identity = matchingBackgroundReceiptIdentity(payload) + if (!identity) return false + const { clientMessageId: owner, taskId, allowProjection } = identity + const terminalEvent = eventKind === 'sessions-changed' + ? sessionChangeIsTerminal(payload) + : isTerminalEvent(eventKind) + // A matching lifecycle frame can beat the replay ACK. Bind only the exact + // client-message owner: unrelated same-session tasks from another tab must + // remain visible and must never enter this quarantine. + rememberBackgroundReceiptTask(owner, taskId) + if (eventKind === 'task-queued') { + rememberBackgroundReceiptTask(owner, taskId, false, undefined, 'queued') + if (allowProjection) { + options.taskOwnership?.noteQueued({ ...payload, status: 'queued' }) + } + } else if (eventKind === 'task-running') { + rememberBackgroundReceiptTask(owner, taskId, false, undefined, 'running') + if (allowProjection) { + options.taskOwnership?.noteRunning({ ...payload, status: 'running' }) + } + } + if (terminalEvent) { + const terminalTask = terminalSessionChangeTask(payload) + const rawStatus = String( + terminalTask?.status + || payload.status + || payload.task_status + || payload.run_status + || payload.runStatus + || '', + ).trim().toLowerCase() + const status = rawStatus + || (eventKind === 'sessions-changed' ? '' : eventTaskTerminalStatus(eventKind)) + || (eventKind === 'turn-failed' ? 'failed' : 'succeeded') + rememberBackgroundReceiptTask(owner, taskId, true) + if (!allowProjection) { + const priority = deferredBackgroundReceiptTerminalPriority(eventKind, payload) + const existing = deferredBackgroundReceiptTerminals.get(taskId) + if ( + !existing + || priority > existing.priority + || (priority === existing.priority && !existing.status && Boolean(status)) + ) { + deferredBackgroundReceiptTerminals.set(taskId, { + clientMessageId: owner, + eventKind, + payload, + status, + terminalTask: terminalTask || payload, + priority, + }) + } + return true + } + options.taskOwnership?.noteTerminal(taskId) + markTaskSettled(payload) + if (!reconciledBackgroundReceiptClientIds.has(owner)) { + dirtyBackgroundReceiptClientIds.add(owner) + } + flushBackgroundReceiptReconciliationIfReady() + // Keep the task identity through the complete terminal echo cluster + // (done -> sessions.changed -> task.* / turn.committed). For a terminal + // session projection, retain an unrelated successor task without allowing + // the receipt's history sync to replace the newer Edit transcript. + const hasContinuation = eventKind === 'sessions-changed' + && applyBackgroundReceiptContinuation(payload, taskId) + settleBackgroundReceiptTerminal( + owner, + taskId, + status, + terminalTask || payload, + !hasContinuation, + ) + } + return true + } + const { sessionKey, currentEpoch, @@ -1357,6 +1778,15 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) } watch(sessionKey, () => { + pendingBackgroundReceiptClientIds.clear() + backgroundReceiptClientIds.clear() + backgroundReceiptTasks.clear() + deferredBackgroundReceiptTerminals.clear() + dirtyBackgroundReceiptClientIds.clear() + reconciledBackgroundReceiptClientIds.clear() + settledBackgroundReceiptClientIds.clear() + backgroundReceiptEditHeld = false + backgroundReceiptHoldSessionKey = '' streamThinking.value = null clearGenerationTracking() turnReasoningLog.length = 0 @@ -2584,7 +3014,10 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) */ function handleConversationEvent(message: ConversationEvent) { if (message.kind === 'sessions-changed') { - handleRpcSessionsChanged(message.payload as SessionEventPayload) + const payload = message.payload as SessionEventPayload + if (!suppressBackgroundReceiptEvent('sessions-changed', payload)) { + handleRpcSessionsChanged(payload) + } return } @@ -2599,6 +3032,13 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) } const event = message.event + if ( + event.kind === 'known' + && suppressBackgroundReceiptEvent( + event.semanticKind, + message.payload as SessionEventPayload, + ) + ) return if (event.kind === 'known') { switch (event.semanticKind) { case 'answer-generation-reset': @@ -2809,5 +3249,10 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) streamThinkingElapsedText, attachTurnReasoning, awaitingCommitTaskIds, + beginBackgroundReceiptReplay, + trackBackgroundReceiptTask, + finishBackgroundReceiptReplay, + holdBackgroundReceiptReconciliation, + releaseBackgroundReceiptReconciliation, } } diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index bd671de052..0350a4aa44 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -4,6 +4,7 @@ import { effectScope, nextTick, ref, watch } from 'vue' import { useChatSend, type UseChatSendOptions as DomainUseChatSendOptions } from './useChatSend' import { createV4TurnCommandsFromRpcClient } from '@/adapters/gateway/turnCommandsV4' import { createLegacyPendingInputQueue } from '@/adapters/gateway/pendingInputQueueV4' +import { decodeConversationEvent } from '@/adapters/gateway/conversationEventsV4' import { useChatRpcEventHandlers } from './useChatRpcEventHandlers' import { snapshotSteerRequest, @@ -19,6 +20,7 @@ import type { ChatRenderedMessage, } from '@/types/chat' import type { CollaborationMode } from '@/types/plans' +import type { PromptAnnotationSnapshot } from '@/types/promptAnnotations' import { useChatPendingQueue, type BusySendMode, @@ -204,6 +206,7 @@ function makeOptions(overrides: SendHarnessOverrides = {}) { stream, normalizeElevatedMode: mode => mode, adoptResponseSession: vi.fn(), + recoverPendingQueueHandoff: vi.fn(async () => true), scheduleHistorySync, schedulePendingDrainAfterTerminal: vi.fn(), flushDeferredPendingDrain: vi.fn(), @@ -230,6 +233,67 @@ function makeOptions(overrides: SendHarnessOverrides = {}) { return { api: useChatSend(options), options, rpc, stream, pendingQueue, metaDiscardDraft } } +function makeEditedMessageState(editedDraft?: string) { + const originalTranscript: ChatMessage[] = [ + { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, + { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, + ] + const sessionKey = ref('agent:main:webchat:test') + const messages = ref(originalTranscript) + const originalOwner = messages.value + const inputText = ref('unrelated draft') + const pendingForkBeforeMessageId = ref(null) + const messageActions = useChatMessageActions({ + sessionKey, + messages, + inputText, + isStreaming: ref(false), + sanitizeCopyText: text => text, + stripTimePrefix: text => text, + autoResizeTextarea: vi.fn(), + sendCurrentInput: vi.fn(), + sendUsageBarrierReplay: vi.fn(async () => false), + focusComposer: vi.fn(), + pendingForkBeforeMessageId, + }) + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'original question', + timeStr: '', + showHeader: false, + sourceIndex: 0, + messageId: 'msg-original', + }) + if (editedDraft !== undefined) inputText.value = editedDraft + return { + sessionKey, + messages, + originalOwner, + inputText, + pendingForkBeforeMessageId, + messageActions, + } +} + +function messageEditAnnotation(): PromptAnnotationSnapshot { + return { + annotationId: 'annotation-edit', + documentId: 'document-1', + documentName: 'page.html', + revisionId: 'revision-1', + generation: 1, + anchorId: 'anchor-edit', + body: 'Change this message', + tagName: 'p', + locator: {}, + quote: 'original question', + sourceExcerpt: null, + sentOrder: 0, + } +} + function sameTurnSteerOptions( expectedTurnId = 'turn-current', ): SendHarnessOverrides { @@ -1194,7 +1258,7 @@ describe('useChatSend attachment payloads', () => { deleteHandoff: async ownerRequestId => { handoffs.delete(ownerRequestId) }, close: () => {}, } - const recoverPendingQueueHandoff = vi.fn(async () => {}) + const recoverPendingQueueHandoff = vi.fn(async () => true) const adoptResponseSession = vi.fn() const rpc = { call: vi.fn(async () => ({ sessionKey: child, replayed: true })), @@ -1338,7 +1402,7 @@ describe('useChatSend attachment payloads', () => { } return true }) - const recoverPendingQueueHandoff = vi.fn().mockResolvedValue(undefined) + const recoverPendingQueueHandoff = vi.fn().mockResolvedValue(true) const { api, rpc } = makeOptions({ sessionKey: ref('agent:main:webchat:another-session'), pendingInputWal, @@ -2067,146 +2131,3220 @@ describe('useChatSend attachment payloads', () => { sendBlockedReason: ref('Model routing is being updated. Wait before sending.'), }) - await api.onSend() + await api.onSend() + + expect(rpc.call).not.toHaveBeenCalled() + expect(options.inputText.value).toBe('hello') + expect(options.messages.value).toEqual([]) + }) + + it('preserves queued and hidden sends while live delivery is blocked', async () => { + const blocker = ref('Live updates are unavailable') + const queued: ChatPendingItem = { + pendingUiId: 'pending-ui-live-blocked', + text: 'keep this queued', + attachments: [], + intent: null, + } + const { api, options, rpc } = makeOptions({ sendBlockedReason: blocker }) + + await expect(api.sendQueuedFollowup(queued)).resolves.toBe('deferred') + await expect(api.sendQueuedSteer(queued)).resolves.toBe('not_sent') + await api.dispatchHiddenSend('provider confirmation', 'Confirmed') + + expect(rpc.call).not.toHaveBeenCalled() + expect(queued).toEqual({ + pendingUiId: 'pending-ui-live-blocked', + text: 'keep this queued', + attachments: [], + intent: null, + }) + expect(options.inputText.value).toBe('hello') + expect(options.messages.value).toEqual([]) + }) + + it('queues an immutable hidden confirmation while live delivery is blocked', async () => { + const enqueueHiddenControl = vi.fn(() => true) + const { api, options, rpc } = makeOptions({ + sendBlockedReason: ref('Live updates are unavailable'), + enqueueHiddenControl, + }) + + await expect( + api.dispatchHiddenSend('provider confirmation', 'Confirmed'), + ).resolves.toMatchObject({ status: 'queued', reason: 'queued' }) + + expect(enqueueHiddenControl).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'provider confirmation', + displayText: 'Confirmed', + clientRequestId: expect.any(String), + sessionKey: 'agent:main:webchat:test', + }), + ) + expect(rpc.call).not.toHaveBeenCalled() + expect(options.inputText.value).toBe('hello') + expect(options.messages.value).toEqual([]) + }) + + it('retries a hidden queue item with one stable request identity and bubble', async () => { + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(Object.assign(new Error('response lost'), { + retryable: true, + })) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-hidden', + }), + } + const queued: ChatPendingItem = { + pendingUiId: 'pending-ui-hidden-retry', + text: 'provider confirmation', + displayTextOverride: 'Confirmed', + attachments: [], + intent: null, + hiddenControl: true, + ownerSessionKey: 'agent:main:webchat:test', + } + const { api, options } = makeOptions({ rpc }) + + await expect(api.dispatchQueuedHiddenSend( + queued, + queued.ownerSessionKey!, + )).resolves.toBe('retryable_failure') + const firstParams = rpc.call.mock.calls[0]?.[1] + expect(queued.hiddenClientRequestId).toBe(firstParams.clientRequestId) + expect(queued.hiddenClientMessageId).toBe(firstParams.clientMessageId) + expect(queued.hiddenVisibleCommitted).toBe(true) + + await expect(api.dispatchQueuedHiddenSend( + queued, + queued.ownerSessionKey!, + )).resolves.toBe('accepted') + + expect(rpc.call.mock.calls[1]?.[1]).toEqual(firstParams) + expect(options.messages.value.filter(message => ( + message.role === 'user' && message.text === 'Confirmed' + ))).toHaveLength(1) + }) + + it('keeps an unknown hidden acceptance in the durable outbox', async () => { + const enqueueHiddenControl = vi.fn(() => true) + const rpc = { + call: vi.fn().mockRejectedValue(Object.assign(new Error('response lost'), { + retryable: true, + })), + } + const { api, options } = makeOptions({ + rpc, + enqueueHiddenControl, + }) + + await expect( + api.dispatchHiddenSend('provider confirmation', 'Confirmed'), + ).resolves.toMatchObject({ status: 'unknown', reason: 'response_unknown' }) + + expect(enqueueHiddenControl).not.toHaveBeenCalled() + expect(options.messages.value.filter(message => message.role === 'error')).toHaveLength(1) + }) + + it('rechecks live delivery after active-project validation resolves', async () => { + const blocker = ref(null) + let finishPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn(() => new Promise( + resolve => { + finishPreflight = () => resolve(null) + }, + )) + const { api, options, rpc } = makeOptions({ + sendBlockedReason: blocker, + validateActiveProjectBeforeSend, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledOnce()) + blocker.value = 'Live updates are unavailable' + finishPreflight() + await send + + expect(rpc.call).not.toHaveBeenCalled() + expect(options.inputText.value).toBe('hello') + expect(options.messages.value).toEqual([]) + }) + + it('abandons an edited send when Escape cancels it during project validation', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState() + + let finishPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn(() => new Promise( + resolve => { + finishPreflight = () => resolve(null) + }, + )) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + validateActiveProjectBeforeSend, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledOnce()) + expect(messageActions.cancelEdit()).toBe(true) + finishPreflight() + await send + + expect(rpc.call).not.toHaveBeenCalled() + expect(messages.value.map(message => message.text)).toEqual([ + 'original question', 'original answer', + ]) + expect(inputText.value).toBe('unrelated draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('abandons an edited send when its transcript owner changes during validation', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState() + + let finishPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn(() => new Promise( + resolve => { + finishPreflight = () => resolve(null) + }, + )) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + validateActiveProjectBeforeSend, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledOnce()) + const replacementTranscript: ChatMessage[] = [ + { role: 'user', text: 'authoritative replacement', ts: null, messageId: 'msg-new' }, + ] + messages.value = replacementTranscript + const replacementOwner = messages.value + inputText.value = 'replacement owner draft' + expect(messageActions.cancelEdit()).toBe(true) + expect(messageActions.editGeneration.value).toBe(2) + expect(pendingForkBeforeMessageId.value).toBeNull() + finishPreflight() + await send + + expect(rpc.call).not.toHaveBeenCalled() + expect(messages.value).toBe(replacementOwner) + expect(inputText.value).toBe('replacement owner draft') + }) + + it('detects transcript replacement during validation without requiring Escape', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState() + + let finishPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn(() => new Promise( + resolve => { + finishPreflight = () => resolve(null) + }, + )) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + validateActiveProjectBeforeSend, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledOnce()) + const replacementTranscript: ChatMessage[] = [ + { role: 'user', text: 'authoritative replacement', ts: null, messageId: 'msg-new' }, + ] + messages.value = replacementTranscript + const replacementOwner = messages.value + inputText.value = 'replacement owner draft' + finishPreflight() + await send + + expect(rpc.call).not.toHaveBeenCalled() + expect(messageActions.editGeneration.value).toBe(2) + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(messages.value).toBe(replacementOwner) + expect(inputText.value).toBe('replacement owner draft') + expect(messageActions.cancelEdit()).toBe(false) + }) + + it('keeps an edited receipt offscreen after its composer owner changes', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const annotation = messageEditAnnotation() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockRejectedValueOnce(new RpcTransportError('Connection closed again', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'should-not-send', + }), + } + const { api, stream } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + promptAnnotationIds: ref(['annotation-edit']), + promptAnnotationSnapshots: () => [annotation], + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + expect(rpc.call).toHaveBeenCalledOnce() + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) + + inputText.value = 'draft typed while retrying' + // An existing live stream and the newer draft remain foreground owners. + // An exact receipt replay may resolve the older request only offscreen. + stream.isStreaming.value = true + await api.onSend() + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]?.clientRequestId).toBe( + rpc.call.mock.calls[0]?.[1]?.clientRequestId, + ) + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) + expect(inputText.value).toBe('draft typed while retrying') + expect(stream.isStreaming.value).toBe(true) + + messages.value = [ + { role: 'user', text: 'authoritative replacement', ts: null, messageId: 'msg-new' }, + ] + const replacementOwner = messages.value + inputText.value = 'replacement owner draft' + await api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(messageActions.editGeneration.value).toBe(2) + expect(messages.value).toBe(replacementOwner) + expect(inputText.value).toBe('replacement owner draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(messageActions.cancelEdit()).toBe(false) + + // The stale receipt was detached by the failed lease validation. Once the + // authoritative work is idle, a later draft is an ordinary fresh send. + stream.isStreaming.value = false + inputText.value = 'ordinary replacement follow-up' + await api.onSend() + expect(rpc.call).toHaveBeenCalledTimes(3) + expect(rpc.call.mock.calls[2]?.[1]).toMatchObject({ + message: 'ordinary replacement follow-up', + }) + expect(rpc.call.mock.calls[2]?.[1]).not.toHaveProperty('forkBeforeMessageId') + expect(rpc.call.mock.calls[2]?.[1]?.clientRequestId).not.toBe( + rpc.call.mock.calls[0]?.[1]?.clientRequestId, + ) + }) + + it.each([ + ['accepted while running', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-recovered', + })], + ['accepted with a complete terminal response', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-recovered-terminal', + task_status: 'failed', + terminal_reason: 'activation_failed', + terminal_message: 'The older request failed after acceptance.', + })], + ['definitely rejected', () => Promise.reject(Object.assign(new Error('database busy'), { + accepted: false, + retryable: true, + }))], + ['still unknown', () => Promise.reject(new RpcTransportError( + 'Connection closed again', + null, + ))], + ] as const)( + 'preserves a newer message edit when an older unknown receipt is %s', + async (_label, replayResult) => { + const { + sessionKey, + messages, + originalOwner, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState() + expect(messageActions.cancelEdit()).toBe(true) + inputText.value = 'later ordinary question' + const promptAnnotationIds = ref([]) + const beginBackgroundReceiptReplay = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockImplementationOnce(replayResult), + } + const { api, options, stream } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + promptAnnotationIds, + modelRoutingMode: ref<'llm_ensemble'>('llm_ensemble'), + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + beginBackgroundReceiptReplay, + }) + + await api.onSend() + const originalRequestId = rpc.call.mock.calls[0]?.[1]?.clientRequestId + const originalClientMessageId = rpc.call.mock.calls[0]?.[1]?.clientMessageId + expect(stream.startStreaming).toHaveBeenCalledTimes(1) + expect(stream.endStreaming).toHaveBeenCalledTimes(1) + expect(messages.value.map(message => message.role)).toEqual([ + 'user', 'assistant', 'user', 'error', + ]) + + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'original question', + timeStr: '', + showHeader: false, + sourceIndex: 0, + messageId: 'msg-original', + }) + inputText.value = 'edited original question' + const editOwner = messages.value + const currentAttachment: Attachment = { + kind: 'staged', + local_id: 901, + name: 'new-edit.png', + mime: 'image/png', + file_uuid: 'new-edit-file', + } + options.pendingAttachments.value = [currentAttachment] + const attachmentOwner = options.pendingAttachments.value[0] + promptAnnotationIds.value = ['current-edit-annotation'] + options.pendingSessionIntent.value = 'new_chat' + + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalClientMessageId, + true, + ) + + await api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]?.clientRequestId).toBe(originalRequestId) + expect(rpc.call.mock.calls[1]?.[1]?.message).toBe('later ordinary question') + expect(messages.value).toBe(editOwner) + expect(messages.value).toEqual([]) + expect(inputText.value).toBe('edited original question') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(options.pendingAttachments.value).toEqual([currentAttachment]) + expect(options.pendingAttachments.value[0]).toBe(attachmentOwner) + expect(promptAnnotationIds.value).toEqual(['current-edit-annotation']) + expect(options.pendingSessionIntent.value).toBe('new_chat') + expect(stream.startStreaming).toHaveBeenCalledTimes(1) + expect(stream.endStreaming).toHaveBeenCalledTimes(1) + expect(options.activeStreamTaskId.value).toBe('') + expect(options.activeStreamSessionKey.value).toBe('') + expect(options.scheduleHistorySync).not.toHaveBeenCalled() + + expect(messageActions.cancelEdit()).toBe(true) + expect(messages.value).toBe(originalOwner) + expect(messages.value.map(message => message.role)).toEqual([ + 'user', 'assistant', 'user', 'error', + ]) + expect(inputText.value).toBe('') + expect(pendingForkBeforeMessageId.value).toBeNull() + }, + ) + + it.each([ + ['accepted', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:old-edit-child', + task_id: 'old-edit-task', + }), false, true], + ['definitely rejected', () => Promise.reject(Object.assign( + new Error('old Edit was rejected'), + { accepted: false, retryable: false }, + )), false, true], + ['rejected after delayed retirement', () => Promise.reject(Object.assign( + new Error('old Edit was rejected'), + { accepted: false, retryable: false }, + )), true, true], + ['rejected after the newer Edit changes', () => Promise.reject(Object.assign( + new Error('old Edit was rejected'), + { accepted: false, retryable: false }, + )), true, false], + ] as const)( + 'settles an older %s Edit receipt before sending the newer Edit click', + async (_label, settleOldReceipt, delayRetirement, shouldSendNewEdit) => { + const newChildSessionKey = 'agent:main:webchat:new-edit-child' + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('older edited question') + const pendingInputWal = memoryHandoffWal() + const beginBackgroundReceiptReplay = vi.fn() + const recoverPendingQueueHandoff = vi.fn(async () => ( + !delayRetirement || recoverPendingQueueHandoff.mock.calls.length > 1 + )) + const adoptResponseSession = vi.fn(async (key: string) => { + sessionKey.value = key + }) + let finishProjectValidation = () => {} + const validateActiveProjectBeforeSend = delayRetirement && shouldSendNewEdit + ? vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockImplementationOnce(() => new Promise(resolve => { + finishProjectValidation = () => resolve(null) + })) + : null + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockImplementationOnce(settleOldReceipt) + .mockResolvedValueOnce({ + sessionKey: newChildSessionKey, + task_id: 'new-edit-task', + }), + } + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + beginBackgroundReceiptReplay, + recoverPendingQueueHandoff, + adoptResponseSession, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + ...(validateActiveProjectBeforeSend ? { validateActiveProjectBeforeSend } : {}), + }) + + await harness.api.onSend() + const oldParams = rpc.call.mock.calls[0]?.[1] + const oldRequestId = oldParams?.clientRequestId + const oldClientMessageId = oldParams?.clientMessageId + + pendingForkBeforeMessageId.value = null + messages.value = [ + { role: 'user', text: 'first question', ts: null, messageId: 'first-user' }, + { role: 'assistant', text: 'first answer', ts: null, messageId: 'first-answer' }, + { role: 'user', text: 'new edit target', ts: null, messageId: 'new-edit-target' }, + { role: 'assistant', text: 'new target answer', ts: null, messageId: 'new-answer' }, + ] + expect(messageActions.cancelEdit()).toBe(false) + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'new edit target', + timeStr: '', + showHeader: false, + sourceIndex: 2, + messageId: 'new-edit-target', + }) + inputText.value = 'newer edited question' + const newerEditOwner = messages.value + expect(messageActions.editActive.value).toBe(true) + expect(pendingForkBeforeMessageId.value).toBe('new-edit-target') + expect(inputText.value).toBe('newer edited question') + + if (delayRetirement) vi.useFakeTimers() + try { + const sendNewEdit = harness.api.onSend() + let repeatedSends: Promise[] = [] + if (delayRetirement) { + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => { + expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce() + }) + await vi.advanceTimersByTimeAsync(0) + repeatedSends = [harness.api.onSend(), harness.api.onSend()] + await vi.advanceTimersByTimeAsync(0) + expect(rpc.call).toHaveBeenCalledTimes(2) + if (!shouldSendNewEdit) inputText.value = 'changed while retirement was pending' + await vi.advanceTimersByTimeAsync(250) + if (validateActiveProjectBeforeSend) { + await vi.waitFor(() => { + expect(validateActiveProjectBeforeSend).toHaveBeenCalledTimes(3) + }) + finishProjectValidation() + } + } + await Promise.all([sendNewEdit, ...repeatedSends]) + } finally { + if (delayRetirement) vi.useRealTimers() + } + + expect(rpc.call).toHaveBeenCalledTimes(shouldSendNewEdit ? 3 : 2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(oldParams) + if (!shouldSendNewEdit) { + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(sessionKey.value).toBe('agent:main:webchat:test') + expect(messages.value).toBe(newerEditOwner) + expect(messageActions.editActive.value).toBe(true) + expect(inputText.value).toBe('changed while retirement was pending') + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + return + } + expect(rpc.call.mock.calls[2]?.[1]).toMatchObject({ + message: 'newer edited question', + forkBeforeMessageId: 'new-edit-target', + }) + expect(rpc.call.mock.calls[2]?.[1]?.clientRequestId).not.toBe(oldRequestId) + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith(oldClientMessageId, true) + expect(adoptResponseSession).toHaveBeenCalledTimes(1) + expect(adoptResponseSession).toHaveBeenCalledWith(newChildSessionKey, expect.any(String)) + expect(sessionKey.value).toBe(newChildSessionKey) + expect(messages.value).toBe(newerEditOwner) + expect(messageActions.editActive.value).toBe(false) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + }, + ) + + it('quarantines an older receipt when Edit starts during project preflight', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState() + expect(messageActions.cancelEdit()).toBe(true) + inputText.value = 'later ordinary question' + + let finishReplayPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn() + .mockResolvedValueOnce(null) + .mockImplementationOnce(() => new Promise(resolve => { + finishReplayPreflight = () => resolve(null) + })) + const beginBackgroundReceiptReplay = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-preflight-receipt', + }), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + validateActiveProjectBeforeSend, + beginBackgroundReceiptReplay, + }) + + await api.onSend() + const originalParams = rpc.call.mock.calls[0]?.[1] + const replay = api.onSend() + await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledTimes(2)) + + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'original question', + timeStr: '', + showHeader: false, + sourceIndex: 0, + messageId: 'msg-original', + }) + inputText.value = 'edited during preflight' + const editOwner = messages.value + + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalParams.clientMessageId, + true, + ) + expect(rpc.call).toHaveBeenCalledOnce() + + finishReplayPreflight() + await replay + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(originalParams) + expect(messages.value).toBe(editOwner) + expect(messages.value).toEqual([]) + expect(inputText.value).toBe('edited during preflight') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + }) + + it('quarantines a same-generation Edit retry when its composer changes during preflight', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const composerRevision = ref(0) + const promptAnnotationIds = ref([]) + let finishReplayPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn() + .mockResolvedValueOnce(null) + .mockImplementationOnce(() => new Promise(resolve => { + finishReplayPreflight = () => resolve(null) + })) + const beginBackgroundReceiptReplay = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-edit-receipt', + }), + } + const { api, options } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + composerRevision, + promptAnnotationIds, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + validateActiveProjectBeforeSend, + beginBackgroundReceiptReplay, + }) + + await api.onSend() + const originalParams = rpc.call.mock.calls[0]?.[1] + const replay = api.onSend() + await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledTimes(2)) + + // Retyping the same visible text is still a new composer owner. The new + // attachment/annotation make the ownership difference independently + // observable even in harnesses that do not expose the UI revision counter. + inputText.value = 'edited question' + composerRevision.value += 1 + pendingForkBeforeMessageId.value = 'msg-original' + const currentAttachment: Attachment = { + kind: 'staged', + local_id: 903, + name: 'same-edit-new.png', + mime: 'image/png', + file_uuid: 'same-edit-new-file', + } + options.pendingAttachments.value = [currentAttachment] + promptAnnotationIds.value = ['same-edit-new-annotation'] + const editOwner = messages.value + + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalParams.clientMessageId, + true, + ) + expect(rpc.call).toHaveBeenCalledOnce() + + finishReplayPreflight() + await replay + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(originalParams) + expect(messages.value).toBe(editOwner) + expect(inputText.value).toBe('edited question') + expect(options.pendingAttachments.value).toEqual([currentAttachment]) + expect(promptAnnotationIds.value).toEqual(['same-edit-new-annotation']) + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + }) + + it('keeps composer changes made during the original ambiguous RPC out of its receipt owner', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const composerRevision = ref(0) + let rejectOriginal!: (reason: Error) => void + const originalResponse = new Promise((_resolve, reject) => { + rejectOriginal = reject + }) + const beginBackgroundReceiptReplay = vi.fn() + const rpc = { + call: vi.fn() + .mockImplementationOnce(() => originalResponse) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-original-race-receipt', + }), + } + const { api, options } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + composerRevision, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + beginBackgroundReceiptReplay, + }) + + const original = api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + inputText.value = 'edited question' + composerRevision.value += 1 + pendingForkBeforeMessageId.value = 'msg-original' + const currentAttachment: Attachment = { + kind: 'staged', + local_id: 904, + name: 'during-rpc.png', + mime: 'image/png', + file_uuid: 'during-rpc-file', + } + options.pendingAttachments.value = [currentAttachment] + + rejectOriginal(new RpcTransportError('Connection closed', null)) + await original + + const originalParams = rpc.call.mock.calls[0]?.[1] + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalParams.clientMessageId, + true, + ) + + await api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(originalParams) + expect(inputText.value).toBe('edited question') + expect(options.pendingAttachments.value).toEqual([currentAttachment]) + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + }) + + it('rechecks exact-replay ownership after the durable handoff lookup', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const composerRevision = ref(0) + let finishReplayHandoff!: () => void + let handoffLookups = 0 + const baseWal = memoryHandoffWal() + const pendingInputWal: PendingInputWal = { + ...baseWal, + listHandoffs: vi.fn(async (requestSessionKey) => { + handoffLookups += 1 + if (handoffLookups === 1) { + await new Promise(resolve => { + finishReplayHandoff = resolve + }) + } + return baseWal.listHandoffs?.(requestSessionKey) || [] + }), + } + const beginBackgroundReceiptReplay = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-handoff-race-receipt', + }), + } + const { api, options } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + composerRevision, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + beginBackgroundReceiptReplay, + }) + + await api.onSend() + const originalParams = rpc.call.mock.calls[0]?.[1] + const replay = api.onSend() + await vi.waitFor(() => expect(pendingInputWal.listHandoffs).toHaveBeenCalledOnce()) + expect(rpc.call).toHaveBeenCalledOnce() + + inputText.value = 'edited question' + composerRevision.value += 1 + pendingForkBeforeMessageId.value = 'msg-original' + const currentAttachment: Attachment = { + kind: 'staged', + local_id: 905, + name: 'handoff-race.png', + mime: 'image/png', + file_uuid: 'handoff-race-file', + } + options.pendingAttachments.value = [currentAttachment] + finishReplayHandoff() + await replay + + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalParams.clientMessageId, + true, + ) + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(originalParams) + expect(inputText.value).toBe('edited question') + expect(options.pendingAttachments.value).toEqual([currentAttachment]) + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + }) + + it.each(['Escape', 'session switch'] as const)( + 'retains an unknown fork handoff when %s invalidates its durable lookup', + async (invalidation) => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + let finishReplayHandoff!: () => void + let handoffLookups = 0 + const baseWal = memoryHandoffWal() + const pendingInputWal: PendingInputWal = { + ...baseWal, + listHandoffs: vi.fn(async (requestSessionKey) => { + handoffLookups += 1 + if (handoffLookups === 1) { + await new Promise(resolve => { + finishReplayHandoff = resolve + }) + } + return baseWal.listHandoffs?.(requestSessionKey) || [] + }), + } + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:child', + task_id: 'must-not-dispatch', + }), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + const ownerRequestId = String(rpc.call.mock.calls[0]?.[1]?.clientRequestId) + const replay = api.onSend() + await vi.waitFor(() => expect(pendingInputWal.listHandoffs).toHaveBeenCalledOnce()) + + if (invalidation === 'Escape') { + expect(messageActions.cancelEdit()).toBe(false) + expect(messageActions.editActive.value).toBe(false) + } else { + sessionKey.value = 'agent:main:webchat:new-draft' + } + finishReplayHandoff() + await replay + + expect(rpc.call).toHaveBeenCalledOnce() + expect(await pendingInputWal.listHandoffs?.()).toEqual([ + expect.objectContaining({ + ownerRequestId, + state: 'submitting', + }), + ]) + }, + ) + + it.each([ + ['resolved response', false], + ['accepted error', true], + ] as const)( + 'keeps an offscreen fork child out of parent ownership and retires its WAL for an %s', + async (_label, acceptedError) => { + const parentSessionKey = 'agent:main:webchat:test' + const childSessionKey = 'agent:main:webchat:child' + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const pendingInputWal = memoryHandoffWal() + const taskOwnership = useChatTaskOwnership() + const adoptResponseSession = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockImplementationOnce(() => acceptedError + ? Promise.reject(Object.assign(new Error('accepted response was lost'), { + accepted: true, + details: { + session_key: childSessionKey, + orphan_message_id: 'child-user-message', + }, + })) + : Promise.resolve({ + sessionKey: childSessionKey, + task_id: 'task-child-receipt', + })), + } + const { api, options } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + taskOwnership, + adoptResponseSession, + hasPendingQueueWork: () => true, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + inputText.value = 'newer edit owner' + pendingForkBeforeMessageId.value = 'msg-original' + await api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(sessionKey.value).toBe(parentSessionKey) + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(taskOwnership.runningTaskId.value).toBe('') + expect(taskOwnership.hasAuthoritativeWork.value).toBe(false) + expect(await pendingInputWal.listHandoffs?.()).toEqual([]) + expect(options.flushDeferredPendingDrain).toHaveBeenCalledOnce() + expect(options.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + expect(inputText.value).toBe('newer edit owner') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + }, + ) + + it('recovers a retained background-only acceptance without adopting its child', async () => { + const childSessionKey = 'agent:main:webchat:child' + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failAcceptedDelete = true + const pendingInputWal: PendingInputWal = { + ...baseWal, + compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { + if (record === null && failAcceptedDelete) { + throw new Error('delete unavailable') + } + return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) + }), + } + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: childSessionKey, + task_id: 'task-child-background', + }), + } + const first = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await first.api.onSend() + inputText.value = 'newer edit owner' + pendingForkBeforeMessageId.value = 'msg-original' + await first.api.onSend() + + expect(await pendingInputWal.listHandoffs?.()).toEqual([ + expect.objectContaining({ + state: 'accepted', + acceptedSessionKey: childSessionKey, + backgroundOnly: true, + }), + ]) + + failAcceptedDelete = false + const adoptResponseSession = vi.fn() + const recovery = makeOptions({ + sessionKey, + pendingInputWal, + adoptResponseSession, + }) + await recovery.api.recoverResponseHandoffs() + + expect(recovery.rpc.call).not.toHaveBeenCalled() + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(sessionKey.value).toBe('agent:main:webchat:test') + expect(await pendingInputWal.listHandoffs?.()).toEqual([]) + }) + + it('retires a failed background-only handoff without restoring its composer payload', async () => { + const parent = 'agent:main:webchat:failed-background-parent' + const ownerRequestId = 'failed-background-request' + const pendingInputWal = memoryHandoffWal() + await pendingInputWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'failed-background-message', + composerText: 'stale recovered text', + recoveryAttachments: [{ + kind: 'staged', + local_id: 911, + name: 'stale.png', + mime: 'image/png', + file_uuid: 'stale-file', + }], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'failed-background-message', + message: 'stale recovered text', + forkBeforeMessageId: 'stale-fork-anchor', + }, + backgroundOnly: true, + walOwnerId: 'failed-background-owner', + walRevision: 3, + state: 'failed', + errorCode: 'rejected', + createdAt: 1, + updatedAt: 2, + }) + const currentAttachment: Attachment = { + kind: 'staged', + local_id: 912, + name: 'current.png', + mime: 'image/png', + file_uuid: 'current-file', + } + const inputText = ref('current composer text') + const pendingAttachments = ref([currentAttachment]) + const pendingForkBeforeMessageId = ref('current-fork-anchor') + const recoverPendingQueueHandoff = vi.fn(async () => true) + const adoptResponseSession = vi.fn() + const harness = makeOptions({ + sessionKey: ref(parent), + inputText, + pendingAttachments, + pendingForkBeforeMessageId, + pendingInputWal, + recoverPendingQueueHandoff, + adoptResponseSession, + }) + + await harness.api.recoverResponseHandoffs() + + expect(harness.rpc.call).not.toHaveBeenCalled() + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(recoverPendingQueueHandoff).toHaveBeenCalledWith(parent, parent, ownerRequestId) + expect(inputText.value).toBe('current composer text') + expect(pendingAttachments.value).toEqual([currentAttachment]) + expect(pendingForkBeforeMessageId.value).toBe('current-fork-anchor') + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + }) + + it('retries a failed crash handoff queue release without waiting for reconnect', async () => { + vi.useFakeTimers() + try { + const parent = 'agent:main:webchat:failed-retry-parent' + const ownerRequestId = 'failed-retry-request' + const pendingInputWal = memoryHandoffWal() + await pendingInputWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'failed-retry-message', + composerText: 'rejected receipt', + recoveryAttachments: [], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'failed-retry-message', + message: 'rejected receipt', + forkBeforeMessageId: 'failed-retry-anchor', + }, + backgroundOnly: true, + walOwnerId: 'failed-retry-owner', + walRevision: 2, + state: 'failed', + errorCode: 'rejected', + createdAt: 1, + updatedAt: 2, + }) + const recoverPendingQueueHandoff = vi.fn(async () => ( + recoverPendingQueueHandoff.mock.calls.length > 1 + )) + const harness = makeOptions({ + sessionKey: ref(parent), + pendingInputWal, + recoverPendingQueueHandoff, + }) + + const recovery = harness.api.recoverResponseHandoffs() + await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) + expect(await pendingInputWal.listHandoffs!()).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(250) + await recovery + + expect(recoverPendingQueueHandoff).toHaveBeenCalledTimes(2) + expect(harness.rpc.call).not.toHaveBeenCalled() + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('retries rejected crash replay retirement without resending the RPC', async () => { + vi.useFakeTimers() + try { + const parent = 'agent:main:webchat:rejected-retry-parent' + const ownerRequestId = 'rejected-retry-request' + const baseWal = memoryHandoffWal() + await baseWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'rejected-retry-message', + composerText: 'rejected receipt', + recoveryAttachments: [], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'rejected-retry-message', + message: 'rejected receipt', + forkBeforeMessageId: 'rejected-retry-anchor', + }, + backgroundOnly: true, + walOwnerId: 'rejected-retry-owner', + walRevision: 2, + state: 'submitting', + createdAt: 1, + updatedAt: 2, + }) + let failRejectedTransition = true + const pendingInputWal: PendingInputWal = { + ...baseWal, + compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { + if (record?.state === 'failed' && failRejectedTransition) { + failRejectedTransition = false + return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } + } + return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) + }), + } + const recoverPendingQueueHandoff = vi.fn(async () => true) + const rpc = { + call: vi.fn().mockRejectedValue(Object.assign(new Error('rejected'), { + accepted: false, + retryable: false, + })), + } + const harness = makeOptions({ + rpc, + sessionKey: ref(parent), + pendingInputWal, + recoverPendingQueueHandoff, + }) + + const recovery = harness.api.recoverResponseHandoffs() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ state: 'submitting', backgroundOnly: true }), + ]) + + await vi.advanceTimersByTimeAsync(250) + await recovery + + expect(rpc.call).toHaveBeenCalledOnce() + expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce() + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('does not adopt a child from a background-only submitting WAL left by a crash', async () => { + const parent = 'agent:main:webchat:parent-background' + const child = 'agent:main:webchat:child-background' + const ownerRequestId = 'background-submitting-request' + const pendingInputWal = memoryHandoffWal() + await pendingInputWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'background-submitting-message', + composerText: 'resolve only the receipt', + recoveryAttachments: [], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'background-submitting-message', + message: 'resolve only the receipt', + forkBeforeMessageId: 'fork-anchor', + }, + backgroundOnly: true, + walOwnerId: 'background-submitting-owner', + walRevision: 1, + state: 'submitting', + createdAt: 1, + updatedAt: 1, + }) + const adoptResponseSession = vi.fn() + const recoverPendingQueueHandoff = vi.fn(async () => true) + const rpc = { + call: vi.fn(async () => ({ + sessionKey: child, + task_id: 'background-submitting-task', + })), + } as unknown as UseChatSendOptions['rpc'] + const recovery = makeOptions({ + rpc, + sessionKey: ref(parent), + pendingInputWal, + adoptResponseSession, + recoverPendingQueueHandoff, + }) + + await recovery.api.recoverResponseHandoffs() + + expect(rpc.call).toHaveBeenCalledOnce() + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(recoverPendingQueueHandoff).toHaveBeenCalledWith(parent, parent, ownerRequestId) + expect(await pendingInputWal.listHandoffs?.()).toEqual([]) + }) + + async function verifyCrashedReceiptRecovery(taskStatus: string, sameSession = false) { + const parent = 'agent:main:webchat:background-recovery-parent' + const child = 'agent:main:webchat:background-recovery-child' + const ownerRequestId = 'background-recovery-request' + const clientMessageId = 'background-recovery-message' + const recoveryFile = new File(['recovery'], 'recovery.txt', { type: 'text/plain' }) + const recoveryAttachment: Attachment = { + kind: 'staged', + local_id: 913, + name: recoveryFile.name, + mime: recoveryFile.type, + file_uuid: 'expired-recovery-file', + expires_at: 1, + file: recoveryFile, + } + const pendingInputWal = memoryHandoffWal() + await pendingInputWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId, + composerText: 'recover the old receipt', + recoveryAttachments: [recoveryAttachment], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId, + message: 'recover the old receipt', + forkBeforeMessageId: 'background-recovery-anchor', + attachments: [{ + type: recoveryAttachment.mime, + name: recoveryAttachment.name, + mime: recoveryAttachment.mime, + file_uuid: recoveryAttachment.file_uuid, + }], + }, + backgroundOnly: true, + walOwnerId: 'background-recovery-owner', + walRevision: 1, + state: 'submitting', + createdAt: 1, + updatedAt: 1, + }) + let resolveRecovery!: (value: unknown) => void + const rpc = { + call: vi.fn(() => { + if (rpc.call.mock.calls.length === 1) { + return Promise.reject(Object.assign(new Error('expired attachment'), { + accepted: false, + retryable: true, + code: 'ATTACHMENT_EXPIRED', + })) + } + return new Promise(resolve => { + resolveRecovery = resolve as (value: unknown) => void + }) + }), + } as unknown as UseChatSendOptions['rpc'] + const taskOwnership = useChatTaskOwnership() + const activeStreamTaskId = ref('') + const messages = ref([{ + role: 'assistant', + text: 'current history', + ts: null, + }]) + const historyOwner = messages.value + let beginReplay = (_clientMessageId: string, _holdHistory?: boolean) => {} + let finishReplay = (_clientMessageId: string) => {} + let trackReplay = ( + _clientMessageId: string, + _taskId: string, + _terminal?: boolean | string, + _allowProjection?: boolean, + _retireParentProjection?: boolean, + _acceptedStatus?: string, + ) => {} + const beginBackgroundReceiptReplay = vi.fn((id: string, holdHistory?: boolean) => { + beginReplay(id, holdHistory) + }) + const finishBackgroundReceiptReplay = vi.fn((id: string) => { + finishReplay(id) + }) + const trackBackgroundReceiptTask = vi.fn(( + id: string, + taskId: string, + terminal?: boolean | string, + allowProjection?: boolean, + retireParentProjection?: boolean, + acceptedStatus?: string, + ) => { + trackReplay( + id, + taskId, + terminal, + allowProjection, + retireParentProjection, + acceptedStatus, + ) + }) + const applySessionRunState = vi.fn() + const scheduleHistorySync = vi.fn() + const schedulePendingDrainAfterTerminal = vi.fn() + const scheduleRecoveredQueueDrain = vi.fn() + let resolveQueueRelease!: (released: boolean) => void + const recoverPendingQueueHandoff = vi.fn(() => new Promise(resolve => { + resolveQueueRelease = resolve + })) + const recovery = makeOptions({ + rpc, + sessionKey: ref(parent), + messages, + pendingInputWal, + taskOwnership, + activeStreamTaskId, + beginBackgroundReceiptReplay, + finishBackgroundReceiptReplay, + trackBackgroundReceiptTask, + scheduleHistorySync, + schedulePendingDrainAfterTerminal: scheduleRecoveredQueueDrain, + recoverPendingQueueHandoff, + messageEditActive: ref(true), + prepareAttachmentsForSend: vi.fn(async ({ attachments }) => { + const attachment = attachments?.[0] + if (attachment?.kind === 'staged') { + attachment.file_uuid = 'refreshed-recovery-file' + attachment.expires_at = Date.now() + 60_000 + } + return true + }), + }) + recovery.pendingQueue.value.push({ + pendingUiId: 'background-recovery-pending', + text: 'keep the parent queue pending', + attachments: [], + intent: null, + ownerSessionKey: parent, + }) + const scope = effectScope() + const rpcEvents = scope.run(() => useChatRpcEventHandlers({ + sessionKey: recovery.options.sessionKey, + currentEpoch: ref(0), + lastStreamSeq: ref(0), + activeTaskGroups: ref(new Set()), + taskOwnership, + activeStreamTaskId, + aborted: recovery.options.aborted, + messages, + pendingQueue: recovery.pendingQueue, + usageAccum: ref({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: null, + routedTurns: 0, + sessionSaved: 0, + }), + usageModel: ref(''), + stream: recovery.stream, + normalizeRunStatus: status => status, + sessionRunStatus: () => ({ status: 'running', label: 'running', task: null }), + applySessionRunState, + queueRouterDecision: vi.fn(), + appendEnsembleProgress: vi.fn(), + markEnsembleHandoff: vi.fn(), + flushPendingRouterDecision: vi.fn(), + clearPendingRouterDecision: vi.fn(), + handleRouterControlReplay: vi.fn(), + showCompactionToast: vi.fn(), + showWarningToast: vi.fn(), + scheduleHistorySync, + schedulePendingDrainAfterTerminal, + popAllPendingIntoComposer: vi.fn(() => false), + saveWidgetState: vi.fn(), + loadCurrentSessionUsage: vi.fn(), + }))! + beginReplay = rpcEvents.beginBackgroundReceiptReplay + finishReplay = rpcEvents.finishBackgroundReceiptReplay + trackReplay = rpcEvents.trackBackgroundReceiptTask + + try { + const restoring = recovery.api.recoverResponseHandoffs() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId, true) + expect(beginBackgroundReceiptReplay).toHaveBeenCalledOnce() + expect(finishBackgroundReceiptReplay).not.toHaveBeenCalled() + expect(rpc.call.mock.calls[1]?.[1]?.attachments?.[0]?.file_uuid).toBe( + 'refreshed-recovery-file', + ) + const deliver = (eventName: string, payload: Record) => { + rpcEvents.onConversationEvent({ + kind: 'conversation', + event: decodeConversationEvent(eventName, payload, {}), + payload, + meta: {}, + }) + } + deliver('task.queued', { + session_key: parent, + task_id: 'recovered-task', + client_message_id: clientMessageId, + }) + deliver('task.running', { + session_key: parent, + task_id: 'recovered-task', + client_message_id: clientMessageId, + }) + if (!sameSession) { + deliver('task.succeeded', { + session_key: parent, + task_id: 'recovered-task', + client_message_id: clientMessageId, + }) + } + expect(activeStreamTaskId.value).toBe('') + expect(taskOwnership.runningTaskId.value).toBe('') + expect(applySessionRunState).not.toHaveBeenCalled() + expect(scheduleHistorySync).not.toHaveBeenCalled() + resolveRecovery({ + sessionKey: sameSession ? parent : child, + task_id: 'recovered-task', + ...(taskStatus ? { task_status: taskStatus } : {}), + }) + await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) + expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( + clientMessageId, + 'recovered-task', + taskStatus, + sameSession, + !sameSession, + taskStatus, + ) + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ + state: 'accepted', + acceptedTaskId: 'recovered-task', + ...(taskStatus ? { acceptedTaskStatus: taskStatus } : {}), + }), + ]) + if (sameSession) { + expect(taskOwnership.runningTaskId.value).toBe('recovered-task') + expect(applySessionRunState).toHaveBeenCalledWith({ + run_status: 'running', + active_task: { + task_id: 'recovered-task', + status: 'running', + }, + }) + expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + + resolveQueueRelease(true) + await restoring + + expect(taskOwnership.runningTaskId.value).toBe('recovered-task') + expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + deliver('session.event.text_delta', { + session_key: parent, + task_id: 'recovered-task', + stream_seq: 1, + text: 'old recovered output', + }) + deliver('task.succeeded', { + session_key: parent, + task_id: 'recovered-task', + }) + expect(recovery.stream.appendDelta).not.toHaveBeenCalled() + expect(taskOwnership.runningTaskId.value).toBe('') + expect(applySessionRunState).toHaveBeenLastCalledWith(expect.objectContaining({ + run_status: 'idle', + last_task: expect.objectContaining({ task_id: 'recovered-task' }), + })) + expect(schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + expect(finishBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + return + } + expect(applySessionRunState).not.toHaveBeenCalled() + expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + expect(scheduleRecoveredQueueDrain).toHaveBeenCalledOnce() + expect(finishBackgroundReceiptReplay).not.toHaveBeenCalled() + deliver('task.queued', { + session_key: parent, + task_id: 'recovered-task', + }) + deliver('session.event.text_delta', { + session_key: parent, + task_id: 'recovered-task', + stream_seq: 1, + text: 'old recovered output', + }) + deliver('task.succeeded', { + session_key: parent, + task_id: 'recovered-task', + }) + rpcEvents.onConversationEvent({ + kind: 'sessions-changed', + payload: { + session_key: child, + reason: 'task_terminal', + run_status: 'idle', + changed_task: { task_id: 'recovered-task', status: 'succeeded' }, + last_task: { task_id: 'recovered-task', status: 'succeeded' }, + }, + meta: {}, + }) + + expect(activeStreamTaskId.value).toBe('') + expect(taskOwnership.runningTaskId.value).toBe('') + expect([...taskOwnership.queuedTaskIds.value]).toEqual([]) + expect(recovery.stream.appendDelta).not.toHaveBeenCalled() + expect(applySessionRunState).not.toHaveBeenCalled() + expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + expect(messages.value).toBe(historyOwner) + expect(messages.value).toEqual([expect.objectContaining({ text: 'current history' })]) + expect(scheduleHistorySync).not.toHaveBeenCalled() + + resolveQueueRelease(true) + await restoring + + expect(finishBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + scope.stop() + } + } + + it.each([ + ['terminal', 'failed'], + ['non-terminal', ''], + ] as const)( + 'quarantines task, turn, and session events after a crashed %s child ACK', + async (_label, taskStatus) => { + await verifyCrashedReceiptRecovery(taskStatus) + }, + ) + + it('keeps a crashed same-session receipt running until its terminal event', async () => { + await verifyCrashedReceiptRecovery('', true) + }) + + it('quarantines and retries a crashed accepted background-only owner', async () => { + vi.useFakeTimers() + try { + const parent = 'agent:main:webchat:accepted-background-parent' + const child = 'agent:main:webchat:accepted-background-child' + const ownerRequestId = 'accepted-background-request' + const clientMessageId = 'accepted-background-message' + const pendingInputWal = memoryHandoffWal() + await pendingInputWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId, + composerText: 'already accepted offscreen', + recoveryAttachments: [], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId, + message: 'already accepted offscreen', + forkBeforeMessageId: 'fork-anchor', + }, + backgroundOnly: true, + acceptedSessionKey: child, + acceptedTaskId: 'accepted-background-task', + acceptedTaskStatus: 'running', + walOwnerId: 'accepted-background-owner', + walRevision: 4, + state: 'accepted', + createdAt: 1, + updatedAt: 2, + }) + const beginBackgroundReceiptReplay = vi.fn() + const finishBackgroundReceiptReplay = vi.fn() + const trackBackgroundReceiptTask = vi.fn() + const recoverPendingQueueHandoff = vi.fn(async () => ( + recoverPendingQueueHandoff.mock.calls.length > 1 + )) + const harness = makeOptions({ + sessionKey: ref(parent), + pendingInputWal, + beginBackgroundReceiptReplay, + finishBackgroundReceiptReplay, + trackBackgroundReceiptTask, + recoverPendingQueueHandoff, + hasPendingQueueWork: () => true, + }) + + const recovery = harness.api.recoverResponseHandoffs() + await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) + + expect(harness.rpc.call).not.toHaveBeenCalled() + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId, false) + expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( + clientMessageId, + 'accepted-background-task', + '', + false, + true, + 'running', + ) + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ + state: 'accepted', + acceptedTaskId: 'accepted-background-task', + acceptedTaskStatus: 'running', + }), + ]) + await vi.advanceTimersByTimeAsync(250) + await recovery + + expect(recoverPendingQueueHandoff).toHaveBeenCalledTimes(2) + expect(finishBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId) + expect(harness.options.flushDeferredPendingDrain).toHaveBeenCalledOnce() + expect(harness.options.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + async function expectBackgroundCasRetry( + shouldFailTransition: (record: ResponseHandoffWalRecord | null) => boolean, + expectedRetainedState: ResponseHandoffWalRecord['state'], + ) { + vi.useFakeTimers() + try { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failTransition = true + const pendingInputWal: PendingInputWal = { + ...baseWal, + compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { + if (shouldFailTransition(record) && failTransition) { + failTransition = false + return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } + } + return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) + }), + } + const acceptanceRecoveryPending = ref(false) + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValue({ + sessionKey: 'agent:main:webchat:child', + task_id: 'background-cas-task', + }), + } + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + acceptanceRecoveryPending, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await harness.api.onSend() + inputText.value = 'newer edit owner' + pendingForkBeforeMessageId.value = 'msg-original' + await harness.api.onSend() + + expect(acceptanceRecoveryPending.value).toBe(true) + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ + state: expectedRetainedState, + backgroundOnly: true, + }), + ]) + + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(acceptanceRecoveryPending.value).toBe(false)) + expect(rpc.call).toHaveBeenCalledTimes(3) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + } + + it.each([ + ['accepted transition', (record: ResponseHandoffWalRecord | null) => ( + record?.state === 'accepted' + ), 'submitting'], + ['final deletion', (record: ResponseHandoffWalRecord | null) => record === null, 'accepted'], + ] as const)( + 'keeps the recovery worker until the background-only %s CAS is durable', + async (_label, shouldFailTransition, expectedRetainedState) => { + await expectBackgroundCasRetry(shouldFailTransition, expectedRetainedState) + }, + ) + + it('does not dispatch a background fork receipt replay before its WAL disposition is durable', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failBackgroundTransition = true + const pendingInputWal: PendingInputWal = { + ...baseWal, + compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { + if ( + failBackgroundTransition + && record?.state === 'submitting' + && record.backgroundOnly === true + ) { + failBackgroundTransition = false + return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } + } + return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) + }), + } + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:child', + task_id: 'background-after-cas', + }), + } + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await harness.api.onSend() + inputText.value = 'newer edit owner' + pendingForkBeforeMessageId.value = 'msg-original' + await harness.api.onSend() + + expect(rpc.call).toHaveBeenCalledOnce() + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ state: 'submitting' }), + ]) + + await harness.api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + }) + + it('persists and quarantines an automatic background receipt before replaying it', async () => { + vi.useFakeTimers() + try { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failedBackgroundWrites = 0 + const pendingInputWal: PendingInputWal = { + ...baseWal, + compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { + if (record?.state === 'submitting' && record.backgroundOnly === true) { + failedBackgroundWrites += 1 + if (failedBackgroundWrites <= 2) { + return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } + } + } + return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) + }), + } + const acceptanceRecoveryPending = ref(false) + const beginBackgroundReceiptReplay = vi.fn() + const finishBackgroundReceiptReplay = vi.fn() + const adoptResponseSession = vi.fn() + let resolveFirstSend!: (value: unknown) => void + let resolveRecoverySend!: (value: unknown) => void + const rpc = { + call: vi.fn(() => { + if (rpc.call.mock.calls.length === 1) { + return new Promise(resolve => { + resolveFirstSend = resolve as (value: unknown) => void + }) + } + return new Promise(resolve => { + resolveRecoverySend = resolve as (value: unknown) => void + }) + }), + } as unknown as UseChatSendOptions['rpc'] + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + acceptanceRecoveryPending, + beginBackgroundReceiptReplay, + finishBackgroundReceiptReplay, + adoptResponseSession, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = harness.api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + messages.value = [{ + role: 'user', + text: 'new transcript owner', + ts: null, + messageId: 'new-owner-message', + }] + inputText.value = 'new composer owner' + resolveFirstSend({ + sessionKey: 'agent:main:webchat:child', + task_id: 'background-worker-task', + }) + await send + + expect(acceptanceRecoveryPending.value).toBe(true) + expect(rpc.call).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(250) + + expect(rpc.call).toHaveBeenCalledOnce() + expect(beginBackgroundReceiptReplay).not.toHaveBeenCalled() + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ state: 'submitting' }), + ]) + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ + state: 'submitting', + backgroundOnly: true, + }), + ]) + expect(beginBackgroundReceiptReplay).toHaveBeenCalledOnce() + expect(beginBackgroundReceiptReplay.mock.invocationCallOrder[0]).toBeLessThan( + rpc.call.mock.invocationCallOrder[1]!, + ) + expect(finishBackgroundReceiptReplay).not.toHaveBeenCalled() + + resolveRecoverySend({ + sessionKey: 'agent:main:webchat:child', + task_id: 'background-worker-task', + }) + await vi.waitFor(() => expect(acceptanceRecoveryPending.value).toBe(false)) + + expect(finishBackgroundReceiptReplay).toHaveBeenCalledOnce() + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it.each(['none', 'failed-state CAS', 'queue release'] as const)( + 'durably retires a definitely rejected automatic background receipt after %s recovery', + async (failure) => { + vi.useFakeTimers() + try { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failAcceptedTransition = true + let failRejectionTransition = failure === 'failed-state CAS' + const pendingInputWal: PendingInputWal = { + ...baseWal, + compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { + if (record?.state === 'accepted' && failAcceptedTransition) { + failAcceptedTransition = false + return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } + } + if (record?.state === 'failed' && failRejectionTransition) { + failRejectionTransition = false + return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } + } + return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) + }), + } + const acceptanceRecoveryPending = ref(false) + const recoverPendingQueueHandoff = vi.fn(async () => ( + failure !== 'queue release' + || recoverPendingQueueHandoff.mock.calls.length > 1 + )) + let resolveFirstSend!: (value: unknown) => void + const rpc = { + call: vi.fn(() => { + if (rpc.call.mock.calls.length === 1) { + return new Promise(resolve => { + resolveFirstSend = resolve as (value: unknown) => void + }) + } + return Promise.reject(Object.assign( + new Error('receipt was definitely rejected'), + { accepted: false, retryable: false }, + )) + }), + } as unknown as UseChatSendOptions['rpc'] + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + acceptanceRecoveryPending, + recoverPendingQueueHandoff, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = harness.api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + messages.value = [{ + role: 'user', + text: 'new transcript owner', + ts: null, + messageId: 'new-owner-message', + }] + inputText.value = 'new composer owner' + resolveFirstSend({ + sessionKey: 'agent:main:webchat:rejected-child', + task_id: 'rejected-background-task', + }) + await send + + expect(acceptanceRecoveryPending.value).toBe(true) + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + + if (failure === 'none') { + await vi.waitFor(() => expect(acceptanceRecoveryPending.value).toBe(false)) + } else { + expect(acceptanceRecoveryPending.value).toBe(true) + expect(await pendingInputWal.listHandoffs!()).toEqual([ + expect.objectContaining({ + state: failure === 'failed-state CAS' ? 'submitting' : 'failed', + backgroundOnly: true, + }), + ]) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(acceptanceRecoveryPending.value).toBe(false)) + } + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(recoverPendingQueueHandoff).toHaveBeenCalledTimes( + failure === 'queue release' ? 2 : 1, + ) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + }, + ) + + it('keeps the recovery worker when background-only WAL persistence fails', async () => { + vi.useFakeTimers() + try { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + let retained: ResponseHandoffWalRecord | null = null + let failBackgroundWrite = true + const pendingInputWal: PendingInputWal = { + put: async () => {}, + list: async () => [], + delete: async () => {}, + putHandoff: async record => { + if (record.backgroundOnly && failBackgroundWrite) { + failBackgroundWrite = false + throw new Error('background disposition write failed') + } + retained = structuredClone(record) + }, + listHandoffs: async () => retained ? [structuredClone(retained)] : [], + deleteHandoff: async () => { retained = null }, + close: () => {}, + } + const acceptanceRecoveryPending = ref(false) + let resolveFirstSend!: (value: unknown) => void + const rpc = { + call: vi.fn(() => { + if (rpc.call.mock.calls.length === 1) { + return new Promise(resolve => { + resolveFirstSend = resolve as (value: unknown) => void + }) + } + return Promise.resolve({ + sessionKey: 'agent:main:webchat:child', + task_id: 'background-put-task', + }) as Promise + }), + } as unknown as UseChatSendOptions['rpc'] + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + acceptanceRecoveryPending, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = harness.api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + messages.value = [{ + role: 'user', + text: 'new transcript owner', + ts: null, + messageId: 'new-owner-message', + }] + inputText.value = 'new composer owner' + resolveFirstSend({ + sessionKey: 'agent:main:webchat:child', + task_id: 'background-put-task', + }) + await send + + expect(acceptanceRecoveryPending.value).toBe(true) + expect(retained).toMatchObject({ state: 'submitting' }) + + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(acceptanceRecoveryPending.value).toBe(false)) + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(retained).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('keeps automatic receipt recovery alive across a failed WAL lookup', async () => { + vi.useFakeTimers() + try { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failRecoveryLookup = true + const pendingInputWal: PendingInputWal = { + ...baseWal, + listHandoffs: vi.fn(async (requestSessionKey) => { + if (failRecoveryLookup) { + failRecoveryLookup = false + throw new Error('WAL lookup unavailable') + } + return baseWal.listHandoffs!(requestSessionKey) + }), + } + const acceptanceRecoveryPending = ref(false) + const taskOwnership = useChatTaskOwnership() + const activeStreamTaskId = ref('') + let rejectFirstSend!: (reason: unknown) => void + const rpc = { + call: vi.fn((method: string) => { + if (method === 'chat.abort') { + return Promise.resolve({ aborted: true }) as Promise + } + if (rpc.call.mock.calls.length === 1) { + return new Promise((_resolve, reject) => { + rejectFirstSend = reject + }) + } + return Promise.resolve({ + sessionKey: 'agent:main:webchat:child', + task_status: 'failed', + }) as Promise + }), + } as unknown as UseChatSendOptions['rpc'] + const adoptResponseSession = vi.fn() + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + acceptanceRecoveryPending, + taskOwnership, + activeStreamTaskId, + adoptResponseSession, + reconcileTaskOwnership: vi.fn(async () => {}), + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = harness.api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + taskOwnership.applySnapshot({ + run_status: 'running', + active_task: { task_id: 'parent-task', status: 'running' }, + }, true) + activeStreamTaskId.value = 'parent-task' + harness.api.onStop() + rejectFirstSend(new RpcTransportError('Connection closed', null)) + await send + + await vi.advanceTimersByTimeAsync(250) + expect(acceptanceRecoveryPending.value).toBe(true) + expect(rpc.call.mock.calls.filter((call: unknown[]) => call[0] === 'chat.send')).toHaveLength(2) + expect(await baseWal.listHandoffs!()).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(acceptanceRecoveryPending.value).toBe(false)) + expect(rpc.call.mock.calls.filter((call: unknown[]) => call[0] === 'chat.send')).toHaveLength(3) + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(await baseWal.listHandoffs!()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('keeps an exact replay pending when its durable handoff lookup fails', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failLookup = true + const pendingInputWal: PendingInputWal = { + ...baseWal, + listHandoffs: vi.fn(async (requestSessionKey) => { + if (failLookup) { + failLookup = false + throw new Error('WAL lookup unavailable') + } + return baseWal.listHandoffs!(requestSessionKey) + }), + } + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValue({ + sessionKey: 'agent:main:webchat:child', + task_id: 'lookup-retry-task', + }), + } + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await harness.api.onSend() + await harness.api.onSend() + expect(rpc.call).toHaveBeenCalledOnce() + expect(await baseWal.listHandoffs!()).toHaveLength(1) + + inputText.value = 'newer edit owner' + pendingForkBeforeMessageId.value = 'msg-original' + await harness.api.onSend() + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(await baseWal.listHandoffs!()).toEqual([]) + }) + + it('does not recreate an exact replay handoff after another recovery retires it', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const pendingInputWal = memoryHandoffWal() + const rpc = { + call: vi.fn().mockRejectedValueOnce(new RpcTransportError('Connection closed', null)), + } + const live = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await live.api.onSend() + expect(await pendingInputWal.listHandoffs?.()).toHaveLength(1) + + const recovery = makeOptions({ + sessionKey: ref('agent:main:webchat:other'), + pendingInputWal, + rpc: { + call: vi.fn().mockResolvedValue({ + sessionKey: 'agent:main:webchat:child', + task_id: 'task-recovered-elsewhere', + }), + }, + }) + await recovery.api.recoverResponseHandoffs() + expect(await pendingInputWal.listHandoffs?.()).toEqual([]) + + await live.api.onSend() + + expect(rpc.call).toHaveBeenCalledOnce() + expect(await pendingInputWal.listHandoffs?.()).toEqual([]) + }) + + it('does not resurrect a handoff from a lookup that lost a concurrent acceptance race', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let releaseLookup!: () => void + let capturedLookup: ResponseHandoffWalRecord[] = [] + const pendingInputWal: PendingInputWal = { + ...baseWal, + listHandoffs: vi.fn(async (requestSessionKey) => { + capturedLookup = await baseWal.listHandoffs!(requestSessionKey) + await new Promise(resolve => { + releaseLookup = resolve + }) + return capturedLookup.map(record => structuredClone(record)) + }), + } + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'same-receipt-after-race', + }), + } + const live = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await live.api.onSend() + const replay = live.api.onSend() + await vi.waitFor(() => expect(pendingInputWal.listHandoffs).toHaveBeenCalledOnce()) + const stale = capturedLookup[0]! + const accepted = { + ...stale, + state: 'accepted' as const, + acceptedSessionKey: 'agent:main:webchat:test', + walRevision: stale.walRevision! + 1, + updatedAt: Date.now(), + } + expect((await baseWal.compareAndSwapHandoff!( + stale.ownerRequestId, + stale.walOwnerId!, + stale.walRevision!, + accepted, + )).applied).toBe(true) + expect((await baseWal.compareAndSwapHandoff!( + accepted.ownerRequestId, + accepted.walOwnerId!, + accepted.walRevision!, + null, + )).applied).toBe(true) + releaseLookup() + await replay + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(await baseWal.listHandoffs?.()).toEqual([]) + }) + + it.each([ + ['different text', 'new ordinary question'], + ['the same text', 'later ordinary question'], + ])( + 'preserves a newer ordinary composer with %s while resolving an older receipt', + async (_label, currentText) => { + const inputText = ref('later ordinary question') + const promptAnnotationIds = ref([]) + const pendingSessionIntent = ref(null) + const beginBackgroundReceiptReplay = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-older-receipt', + }), + } + const { api, options, stream } = makeOptions({ + rpc, + inputText, + promptAnnotationIds, + pendingSessionIntent, + modelRoutingMode: ref<'llm_ensemble'>('llm_ensemble'), + beginBackgroundReceiptReplay, + }) + + await api.onSend() + const originalParams = rpc.call.mock.calls[0]?.[1] + inputText.value = currentText + const currentAttachment: Attachment = { + kind: 'staged', + local_id: 902, + name: 'ordinary-draft.png', + mime: 'image/png', + file_uuid: 'ordinary-draft-file', + } + options.pendingAttachments.value = [currentAttachment] + const attachmentOwner = options.pendingAttachments.value[0] + promptAnnotationIds.value = ['ordinary-draft-annotation'] + pendingSessionIntent.value = 'new_chat' + + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalParams.clientMessageId, + false, + ) + await api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(originalParams) + expect(inputText.value).toBe(currentText) + expect(options.pendingAttachments.value).toEqual([currentAttachment]) + expect(options.pendingAttachments.value[0]).toBe(attachmentOwner) + expect(promptAnnotationIds.value).toEqual(['ordinary-draft-annotation']) + expect(pendingSessionIntent.value).toBe('new_chat') + expect(stream.startStreaming).toHaveBeenCalledTimes(1) + expect(stream.endStreaming).toHaveBeenCalledTimes(1) + expect(options.activeStreamTaskId.value).toBe('') + }, + ) + + it.each([ + ['terminal response', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-terminal-receipt', + task_status: 'timeout', + }), 'task-terminal-receipt', 'timeout'], + ['QUEUE_FULL_DIRTY accepted error', () => Promise.reject(Object.assign( + new Error('queue bookkeeping failed'), + { + code: 'QUEUE_FULL_DIRTY', + accepted: true, + details: { + session_key: 'agent:main:webchat:test', + orphan_message_id: 'message-terminal-receipt', + }, + }, + )), '', 'failed'], + ] as const)( + 'forwards an offscreen %s to the terminal receipt boundary', + async (_label, terminalResult, expectedTaskId, expectedStatus) => { + const inputText = ref('older question') + const trackBackgroundReceiptTask = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockImplementationOnce(terminalResult), + } + const harness = makeOptions({ rpc, inputText, trackBackgroundReceiptTask }) + + await harness.api.onSend() + const clientMessageId = String(rpc.call.mock.calls[0]?.[1]?.clientMessageId) + inputText.value = 'newer question' + await harness.api.onSend() + + expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( + clientMessageId, + expectedTaskId, + expectedStatus, + ...(expectedTaskId ? [true, false, expectedStatus] : []), + ) + expect(inputText.value).toBe('newer question') + }, + ) + + it('does not project a non-fork receipt terminal into a session selected during replay', async () => { + const requestSessionKey = 'agent:main:webchat:receipt-origin' + const sessionKey = ref(requestSessionKey) + const inputText = ref('older question') + const trackBackgroundReceiptTask = vi.fn() + let resolveReplay!: (value: unknown) => void + const rpc = { + call: vi.fn(() => { + if (rpc.call.mock.calls.length === 1) { + return Promise.reject(new RpcTransportError('Connection closed', null)) + } + return new Promise(resolve => { + resolveReplay = resolve as (value: unknown) => void + }) + }), + } as unknown as UseChatSendOptions['rpc'] + const harness = makeOptions({ + rpc, + sessionKey, + inputText, + trackBackgroundReceiptTask, + }) + + await harness.api.onSend() + inputText.value = 'newer composer owner' + const replay = harness.api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + sessionKey.value = 'agent:main:webchat:selected-during-replay' + resolveReplay({ + sessionKey: requestSessionKey, + task_id: 'terminal-old-receipt', + task_status: 'timeout', + }) + await replay + + expect(rpc.call.mock.calls[0]?.[1]).not.toHaveProperty('forkBeforeMessageId') + expect(rpc.call.mock.calls[1]?.[1]).toEqual(rpc.call.mock.calls[0]?.[1]) + expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( + expect.any(String), + 'terminal-old-receipt', + 'timeout', + false, + false, + 'timeout', + ) + expect(sessionKey.value).toBe('agent:main:webchat:selected-during-replay') + }) + + it('registers a manual offscreen child receipt for task-id-only quarantine', async () => { + const requestSessionKey = 'agent:main:webchat:manual-receipt-parent' + const inputText = ref('older question') + const trackBackgroundReceiptTask = vi.fn() + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: 'agent:main:webchat:manual-receipt-child', + task_id: 'manual-child-task', + task_status: 'running', + }), + } + const harness = makeOptions({ + rpc, + sessionKey: ref(requestSessionKey), + inputText, + trackBackgroundReceiptTask, + }) + + await harness.api.onSend() + const clientMessageId = String(rpc.call.mock.calls[0]?.[1]?.clientMessageId) + inputText.value = 'newer composer owner' + await harness.api.onSend() + + expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( + clientMessageId, + 'manual-child-task', + '', + false, + true, + 'running', + ) + expect(inputText.value).toBe('newer composer owner') + }) + + it('does not start async queue persistence for a fork edit while work is active', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState() + + let finishQueuePersistence: (() => void) | undefined + const enqueuePendingInput = vi.fn(() => new Promise(resolve => { + finishQueuePersistence = () => resolve(true) + })) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + acceptanceStopPending: ref(true), + enqueuePendingInput, + }) + + const send = api.onSend() + expect(messageActions.cancelEdit()).toBe(true) + finishQueuePersistence?.() + await send + + expect(enqueuePendingInput).not.toHaveBeenCalled() + expect(rpc.call).not.toHaveBeenCalled() + expect(messages.value.map(message => message.text)).toEqual([ + 'original question', 'original answer', + ]) + expect(inputText.value).toBe('unrelated draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('abandons a fork edit when work becomes busy during handoff persistence', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + + let finishHandoff!: () => void + const baseWal = memoryHandoffWal() + const pendingInputWal: PendingInputWal = { + ...baseWal, + prepareHandoff: vi.fn(async (record) => { + const prepared = await baseWal.prepareHandoff!(record) + await new Promise(resolve => { + finishHandoff = resolve + }) + return prepared + }), + } + const acceptanceStopPending = ref(false) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + acceptanceStopPending, + messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(pendingInputWal.prepareHandoff).toHaveBeenCalled()) + acceptanceStopPending.value = true + finishHandoff() + await send + + expect(rpc.call).not.toHaveBeenCalled() + expect(messages.value).toEqual([]) + expect(inputText.value).toBe('edited question') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.validateEditOwner(messageActions.editGeneration.value)).toBe(true) + }) + + it('keeps definitely rejected edited retries cancelable', async () => { + const { + sessionKey, + messages, + originalOwner, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const annotation = messageEditAnnotation() + const rpc = { + call: vi.fn().mockRejectedValue(Object.assign(new Error('database busy'), { + accepted: false, + retryable: true, + })), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + promptAnnotationIds: ref(['annotation-edit']), + promptAnnotationSnapshots: () => [annotation], + messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) + expect(inputText.value).toBe('edited question') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + + await api.onSend() + + expect(messages.value.map(message => message.role)).toEqual(['user', 'error', 'error']) + expect(inputText.value).toBe('edited question') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + expect(messages.value).toBe(originalOwner) + expect(messages.value.map(message => message.text)).toEqual([ + 'original question', 'original answer', + ]) + expect(inputText.value).toBe('unrelated draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('keeps an unknown edit cancelable after its exact replay is definitely rejected', async () => { + const { + sessionKey, + messages, + originalOwner, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockRejectedValueOnce(Object.assign(new Error('database busy'), { + accepted: false, + retryable: true, + })), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + const requestId = rpc.call.mock.calls[0]?.[1]?.clientRequestId + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) + expect(inputText.value).toBe('') + + await api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]?.clientRequestId).toBe(requestId) + expect(messages.value.map(message => message.role)).toEqual(['user', 'error', 'error']) + expect(inputText.value).toBe('edited question') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + + expect(messageActions.cancelEdit()).toBe(true) + expect(messages.value).toBe(originalOwner) + expect(messages.value.map(message => message.text)).toEqual([ + 'original question', 'original answer', + ]) + expect(inputText.value).toBe('unrelated draft') + expect(pendingForkBeforeMessageId.value).toBeNull() + }) + + it('refuses to adopt a same-client replacement of its optimistic edit row', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + let rejectSend!: (reason: unknown) => void + const rpc = { + call: vi.fn(() => new Promise((_resolve, reject) => { + rejectSend = reject + })), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + const optimistic = messages.value[0]! + const replacement: ChatMessage = { ...optimistic } + messages.value.splice(0, 1, replacement) + const replacementOwner = messages.value[0] + inputText.value = 'replacement owner draft' + pendingForkBeforeMessageId.value = 'msg-authoritative-fork' + rejectSend(Object.assign(new Error('database busy'), { + accepted: false, + retryable: true, + })) + await send + + expect(messages.value[0]).toBe(replacementOwner) + expect(messages.value.map(message => message.role)).toEqual(['user']) + expect(inputText.value).toBe('replacement owner draft') + expect(messageActions.editGeneration.value).toBe(2) + expect(pendingForkBeforeMessageId.value).toBe('msg-authoritative-fork') + expect(messageActions.cancelEdit()).toBe(false) + expect(messages.value.map(message => message.text)).toEqual(['edited question']) + }) + + it('does not mutate an authoritative transcript that replaces an edit during rejection', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + let rejectSend!: (reason: unknown) => void + const rpc = { + call: vi.fn(() => new Promise((_resolve, reject) => { + rejectSend = reject + })), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + const send = api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + const replacementTranscript: ChatMessage[] = [ + { role: 'user', text: 'authoritative replacement', ts: null, messageId: 'msg-new' }, + { role: 'assistant', text: 'authoritative answer', ts: null, messageId: 'msg-new-answer' }, + ] + messages.value = replacementTranscript + const replacementOwner = messages.value + const replacementItems = [...replacementOwner] + inputText.value = 'replacement owner draft\nbyte exact' + + rejectSend(Object.assign(new Error('database busy'), { + accepted: false, + retryable: true, + })) + await send - expect(rpc.call).not.toHaveBeenCalled() - expect(options.inputText.value).toBe('hello') - expect(options.messages.value).toEqual([]) + expect(messages.value).toBe(replacementOwner) + expect(messages.value).toEqual(replacementItems) + expect(messages.value[0]).toBe(replacementItems[0]) + expect(messages.value[1]).toBe(replacementItems[1]) + expect(inputText.value).toBe('replacement owner draft\nbyte exact') + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(messageActions.editGeneration.value).toBe(2) + expect(messageActions.cancelEdit()).toBe(false) }) - it('preserves queued and hidden sends while live delivery is blocked', async () => { - const blocker = ref('Live updates are unavailable') - const queued: ChatPendingItem = { - pendingUiId: 'pending-ui-live-blocked', - text: 'keep this queued', - attachments: [], - intent: null, - } - const { api, options, rpc } = makeOptions({ sendBlockedReason: blocker }) + it.each([ + ['fulfilled terminal response', 'array replacement'], + ['fulfilled terminal response', 'same-client replacement'], + ['accepted error', 'array replacement'], + ['accepted error', 'same-client replacement'], + ] as const)( + 'keeps a %s off a newer %s owner', + async (responseKind, replacementKind) => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + let finishSend!: () => void + const rpc = { + call: vi.fn(() => new Promise((resolve, reject) => { + finishSend = () => { + if (responseKind === 'accepted error') { + reject(Object.assign(new Error('accepted without response'), { + accepted: true, + details: { + orphan_message_id: 'msg-stale-edit', + session_key: sessionKey.value, + }, + })) + return + } + resolve({ + sessionKey: sessionKey.value, + task_id: 'task-stale-edit', + task_status: 'failed', + terminal_reason: 'activation_failed', + terminal_message: 'The stale edit failed after acceptance.', + }) + } + })), + } + const { api, options, stream } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) - await expect(api.sendQueuedFollowup(queued)).resolves.toBe('deferred') - await expect(api.sendQueuedSteer(queued)).resolves.toBe('not_sent') - await api.dispatchHiddenSend('provider confirmation', 'Confirmed') + const send = api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledOnce()) + let replacementOwner: ChatMessage[] + let replacementItems: ChatMessage[] + if (replacementKind === 'array replacement') { + messages.value = [ + { role: 'user', text: 'authoritative replacement', ts: null, messageId: 'msg-new' }, + { role: 'assistant', text: 'authoritative answer', ts: null, messageId: 'msg-new-answer' }, + ] + replacementOwner = messages.value + replacementItems = [...replacementOwner] + } else { + replacementOwner = messages.value + const replacement = { + ...messages.value[0]!, + text: 'same-client authoritative replacement', + } + messages.value.splice(0, 1, replacement) + replacementItems = [messages.value[0]!] + } + inputText.value = 'replacement owner draft' + pendingForkBeforeMessageId.value = 'msg-authoritative-fork' - expect(rpc.call).not.toHaveBeenCalled() - expect(queued).toEqual({ - pendingUiId: 'pending-ui-live-blocked', - text: 'keep this queued', - attachments: [], - intent: null, - }) - expect(options.inputText.value).toBe('hello') - expect(options.messages.value).toEqual([]) - }) + finishSend() + await send - it('queues an immutable hidden confirmation while live delivery is blocked', async () => { - const enqueueHiddenControl = vi.fn(() => true) - const { api, options, rpc } = makeOptions({ - sendBlockedReason: ref('Live updates are unavailable'), - enqueueHiddenControl, + expect(messages.value).toBe(replacementOwner) + expect(messages.value).toEqual(replacementItems) + replacementItems.forEach((message, index) => { + expect(messages.value[index]).toBe(message) + }) + expect(messages.value.some(message => message.role === 'error')).toBe(false) + expect(inputText.value).toBe('replacement owner draft') + expect(pendingForkBeforeMessageId.value).toBe('msg-authoritative-fork') + expect(messageActions.editActive.value).toBe(false) + expect(messageActions.cancelEdit()).toBe(false) + expect(options.scheduleHistorySync).not.toHaveBeenCalled() + expect(stream.endStreaming).toHaveBeenCalledOnce() + }, + ) + + it('never restores an edited send explicitly reported as accepted', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const rpc = { + call: vi.fn().mockRejectedValue(Object.assign(new Error('response lost'), { + accepted: true, + details: { + orphan_message_id: 'msg-edited', + session_key: sessionKey.value, + }, + })), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) - await expect( - api.dispatchHiddenSend('provider confirmation', 'Confirmed'), - ).resolves.toMatchObject({ status: 'queued', reason: 'queued' }) + await api.onSend() + const committedOwner = messages.value - expect(enqueueHiddenControl).toHaveBeenCalledWith( - expect.objectContaining({ - text: 'provider confirmation', - displayText: 'Confirmed', - clientRequestId: expect.any(String), - sessionKey: 'agent:main:webchat:test', - }), - ) - expect(rpc.call).not.toHaveBeenCalled() - expect(options.inputText.value).toBe('hello') - expect(options.messages.value).toEqual([]) + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) + expect(messages.value[0]?.messageId).toBe('msg-edited') + expect(inputText.value).toBe('') + expect(pendingForkBeforeMessageId.value).toBeNull() + expect(messageActions.cancelEdit()).toBe(false) + expect(messages.value).toBe(committedOwner) + expect(messages.value.map(message => message.text)).toEqual([ + 'edited question', expect.stringContaining('response lost'), + ]) + + inputText.value = 'ordinary follow-up after committed edit' + await api.onSend() + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toMatchObject({ + message: 'ordinary follow-up after committed edit', + }) + expect(rpc.call.mock.calls[1]?.[1]).not.toHaveProperty('forkBeforeMessageId') }) - it('retries a hidden queue item with one stable request identity and bubble', async () => { + it('starts a fresh receipt when the same edit is re-entered after cancellation', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') const rpc = { call: vi.fn() - .mockRejectedValueOnce(Object.assign(new Error('response lost'), { + .mockRejectedValueOnce(Object.assign(new Error('database busy'), { + accepted: false, retryable: true, })) + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) .mockResolvedValueOnce({ - sessionKey: 'agent:main:webchat:test', - task_id: 'task-hidden', + sessionKey: sessionKey.value, + task_id: 'task-replayed', }), } - const queued: ChatPendingItem = { - pendingUiId: 'pending-ui-hidden-retry', - text: 'provider confirmation', - displayTextOverride: 'Confirmed', - attachments: [], - intent: null, - hiddenControl: true, - ownerSessionKey: 'agent:main:webchat:test', - } - const { api, options } = makeOptions({ rpc }) - - await expect(api.dispatchQueuedHiddenSend( - queued, - queued.ownerSessionKey!, - )).resolves.toBe('retryable_failure') - const firstParams = rpc.call.mock.calls[0]?.[1] - expect(queued.hiddenClientRequestId).toBe(firstParams.clientRequestId) - expect(queued.hiddenClientMessageId).toBe(firstParams.clientMessageId) - expect(queued.hiddenVisibleCommitted).toBe(true) - - await expect(api.dispatchQueuedHiddenSend( - queued, - queued.ownerSessionKey!, - )).resolves.toBe('accepted') - - expect(rpc.call.mock.calls[1]?.[1]).toEqual(firstParams) - expect(options.messages.value.filter(message => ( - message.role === 'user' && message.text === 'Confirmed' - ))).toHaveLength(1) - }) - - it('keeps an unknown hidden acceptance in the durable outbox', async () => { - const enqueueHiddenControl = vi.fn(() => true) - const rpc = { - call: vi.fn().mockRejectedValue(Object.assign(new Error('response lost'), { - retryable: true, - })), - } - const { api, options } = makeOptions({ + const { api } = makeOptions({ rpc, - enqueueHiddenControl, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) - await expect( - api.dispatchHiddenSend('provider confirmation', 'Confirmed'), - ).resolves.toMatchObject({ status: 'unknown', reason: 'response_unknown' }) - - expect(enqueueHiddenControl).not.toHaveBeenCalled() - expect(options.messages.value.filter(message => message.role === 'error')).toHaveLength(1) - }) - - it('rechecks live delivery after active-project validation resolves', async () => { - const blocker = ref(null) - let finishPreflight!: () => void - const validateActiveProjectBeforeSend = vi.fn(() => new Promise( - resolve => { - finishPreflight = () => resolve(null) - }, - )) - const { api, options, rpc } = makeOptions({ - sendBlockedReason: blocker, - validateActiveProjectBeforeSend, + await api.onSend() + const rejectedRequestId = rpc.call.mock.calls[0]?.[1]?.clientRequestId + expect(messageActions.cancelEdit()).toBe(true) + messageActions.editMessage({ + role: 'user', + displayRole: 'user', + roleLabel: 'User', + text: 'original question', + timeStr: '', + showHeader: false, + sourceIndex: 0, + messageId: 'msg-original', }) + inputText.value = 'edited question' - const send = api.onSend() - await vi.waitFor(() => expect(validateActiveProjectBeforeSend).toHaveBeenCalledOnce()) - blocker.value = 'Live updates are unavailable' - finishPreflight() - await send + await api.onSend() + const newRequestId = rpc.call.mock.calls[1]?.[1]?.clientRequestId + expect(newRequestId).not.toBe(rejectedRequestId) + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) - expect(rpc.call).not.toHaveBeenCalled() - expect(options.inputText.value).toBe('hello') - expect(options.messages.value).toEqual([]) + await api.onSend() + expect(rpc.call).toHaveBeenCalledTimes(3) + expect(rpc.call.mock.calls[2]?.[1]?.clientRequestId).toBe(newRequestId) }) it('sends the clicked snapshot without clearing edits made during project validation', async () => { @@ -3123,6 +6261,51 @@ describe('useChatSend attachment payloads', () => { expect(pendingForkBeforeMessageId.value).toBeNull() }) + it('adopts a regenerated child after replaying an unknown receipt outside Edit mode', async () => { + const parentSessionKey = 'agent:main:webchat:regenerate-parent' + const childSessionKey = 'agent:main:webchat:regenerate-child' + const sessionKey = ref(parentSessionKey) + const inputText = ref('regenerate this question') + const pendingForkBeforeMessageId = ref('message-to-regenerate') + const pendingInputWal = memoryHandoffWal() + const beginBackgroundReceiptReplay = vi.fn() + const adoptResponseSession = vi.fn(async (key: string) => { + sessionKey.value = key + }) + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: childSessionKey, + task_id: 'regenerated-child-task', + }), + } + const harness = makeOptions({ + rpc, + sessionKey, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + beginBackgroundReceiptReplay, + adoptResponseSession, + messageEditGeneration: ref(0), + messageEditActive: ref(false), + }) + + await harness.api.onSend() + const originalParams = rpc.call.mock.calls[0]?.[1] + const ownerRequestId = String(originalParams?.clientRequestId) + + await harness.api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(2) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(originalParams) + expect(beginBackgroundReceiptReplay).not.toHaveBeenCalled() + expect(adoptResponseSession).toHaveBeenCalledWith(childSessionKey, ownerRequestId) + expect(sessionKey.value).toBe(childSessionKey) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + }) + it('switches the session lifecycle when a stopped turn is edited into a child session', async () => { const parentSessionKey = 'agent:main:webchat:parent' const childSessionKey = 'agent:main:webchat:child' @@ -3233,6 +6416,7 @@ describe('useChatSend attachment payloads', () => { // terminal closes the live stream. harness.stream.endStreaming({ reason: 'aborted' }) const actions = useChatMessageActions({ + sessionKey: ref(parentSessionKey), messages, inputText, isStreaming: harness.stream.isStreaming, @@ -3960,9 +7144,9 @@ describe('useChatSend attachment payloads', () => { }) await harness.api.onSend() - harness.stream.isStreaming.value = true const retry = harness.api.onSend() await vi.waitFor(() => expect(sendCount).toBe(2)) + harness.stream.isStreaming.value = true const ownerRequestId = String(rpcCall.mock.calls[1]?.[1]?.clientRequestId) inputText.value = 'follow the recovered edit' @@ -4026,10 +7210,10 @@ describe('useChatSend attachment payloads', () => { }) await harness.api.onSend() - harness.stream.isStreaming.value = true - harness.options.activeStreamSessionKey.value = parentSessionKey const retry = harness.api.onSend() await vi.waitFor(() => expect(sendCount).toBe(2)) + harness.stream.isStreaming.value = true + harness.options.activeStreamSessionKey.value = parentSessionKey // The ambient parent run can finish while the idempotent fork retry is // still waiting for its canonical child response. @@ -5995,6 +9179,111 @@ describe('useChatSend attachment payloads', () => { } }) + it.each([ + ['resolved child response', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:child', + task_id: 'task-child-recovered', + task_status: 'running', + })], + ['accepted child error', () => Promise.reject(Object.assign(new Error('accepted response lost'), { + accepted: true, + details: { + session_key: 'agent:main:webchat:child', + orphan_message_id: 'message-child-recovered', + }, + }))], + ['terminal accepted child error', () => Promise.reject(Object.assign(new Error('queue bookkeeping failed'), { + code: 'QUEUE_FULL_DIRTY', + accepted: true, + details: { + session_key: 'agent:main:webchat:child', + orphan_message_id: 'message-child-terminal', + }, + }))], + ] as const)( + 'keeps an automatically recovered fork off the parent for a %s', + async (_label, recoveredResult) => { + vi.useFakeTimers() + try { + const parentSessionKey = 'agent:main:webchat:test' + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const pendingInputWal = memoryHandoffWal() + const taskOwnership = useChatTaskOwnership() + const activeStreamTaskId = ref('') + let rejectFirstSend!: (reason: unknown) => void + let sendCalls = 0 + const rpc = { + call: vi.fn((method: string) => { + if (method === 'chat.abort') { + return Promise.resolve({ aborted: true }) as Promise + } + sendCalls += 1 + if (sendCalls === 1) { + return new Promise((_resolve, reject) => { + rejectFirstSend = reject + }) + } + return recoveredResult() as Promise + }) as UseChatSendOptions['rpc']['call'], + } + const adoptResponseSession = vi.fn() + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + taskOwnership, + activeStreamTaskId, + adoptResponseSession, + reconcileTaskOwnership: vi.fn(async () => {}), + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + harness.stream.startStreaming = vi.fn(() => { + harness.stream.isStreaming.value = true + }) + harness.stream.endStreaming = vi.fn(() => { + harness.stream.isStreaming.value = false + }) + + const firstSend = harness.api.onSend() + await vi.waitFor(() => expect(sendCalls).toBe(1)) + taskOwnership.applySnapshot({ + run_status: 'running', + active_task: { task_id: 'task-parent-A', status: 'running' }, + }, true) + activeStreamTaskId.value = 'task-parent-A' + harness.api.onStop() + rejectFirstSend(Object.assign(new Error('response lost'), { retryable: true })) + await firstSend + + await vi.runAllTimersAsync() + await Promise.resolve() + + expect(sendCalls).toBe(2) + expect(sessionKey.value).toBe(parentSessionKey) + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(taskOwnership.runningTaskId.value).toBe('task-parent-A') + expect([...taskOwnership.queuedTaskIds.value]).not.toContain('task-child-recovered') + expect(await pendingInputWal.listHandoffs?.()).toEqual([]) + expect(messages.value.find(message => message.messageId?.startsWith('message-child'))).toBeUndefined() + } finally { + vi.useRealTimers() + } + }, + ) + it('retries the exact recovered task Stop when its first abort is not acknowledged', async () => { vi.useFakeTimers() try { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 326a499264..c6595fd52c 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -1,4 +1,4 @@ -import { computed, ref, watch, type ComputedRef, type Ref } from 'vue' +import { computed, ref, toRaw, watch, type ComputedRef, type Ref } from 'vue' import i18n from '@/i18n' import { useToasts } from '@/composables/useToasts' import type { RpcClientError } from '@/lib/rpc' @@ -181,8 +181,18 @@ interface SendAttempt { handoffWalOwnerId?: string handoffWalRevision?: number replayCoordinationKey?: string + /** Exact local transcript owner for a fork whose acceptance may need replay. */ + messageEditTranscriptOwner?: { + generation: number + messages: ChatMessage[] + messageOwners: ChatMessage[] + baseMessageCount: number + cancelableMessageCount: number + } params: TurnSendParams requiresIdempotentReplay?: boolean + /** Exact composer owner captured when this unknown attempt first became retryable. */ + recoveryComposerSnapshot?: ComposerSnapshot // A Stop issued before durable acceptance is known belongs to this exact // idempotent request, not to whichever session happens to be visible later. stopRequested?: boolean @@ -191,7 +201,12 @@ interface SendAttempt { } acceptanceResolved?: boolean acceptanceInFlight?: boolean + /** A background receipt was rejected and only its durable retirement remains. */ + backgroundRejectionPending?: boolean + backgroundRejectionError?: unknown + backgroundRejectionContinuationClaimed?: boolean acceptedTaskId?: string + acceptedTaskStatus?: string acceptedSessionKey?: string stopAbortPromise?: Promise | null autoRecoverAcceptance?: boolean @@ -213,6 +228,7 @@ interface ExplicitSendPayload { interface ComposerSnapshot { revision: number | null + ownershipEpoch: number inputText: string promptAnnotationIds: string[] documentContext: TurnDocumentContext | null @@ -224,6 +240,8 @@ interface ComposerSnapshot { initialCollaborationMode: CollaborationMode | null initialRoutingMode: GatewayModelRoutingMode | null queueOwnerRequestId: string | null + messageEditActive: boolean + messageEditGeneration: number | null } interface DispatchSendOptions { @@ -242,10 +260,16 @@ interface DispatchSendOptions { acceptedVisibleReplay?: { forkBeforeMessageId: string } /** Protocol replays keep a rejected attempt on their own surface. */ suppressRejectedFailureMessage?: boolean + /** Resolve an older receipt without claiming or mutating the visible stream. */ + backgroundReceiptReplay?: boolean + /** The rejected background receipt and its queue owner were durably retired. */ + onBackgroundRejectionRetired?: () => void + /** Wait for delayed retirement before continuing the same user action. */ + onBackgroundRejectionRetirement?: (retirement: Promise) => void /** Preserve an explicit empty attachment list on the chat.send wire. */ includeEmptyAttachments?: boolean /** Revalidate protocol-owned sends after every awaited pre-dispatch step. */ - preDispatchGuard?: (stage: 'preflight' | 'before_rpc') => boolean + preDispatchGuard?: (stage: 'preflight' | 'after_mutation' | 'before_rpc') => boolean /** Require a non-replayable WAL preparation that is armed immediately before RPC. */ requirePreparedHandoff?: boolean /** Stable cross-tab identity for one protocol-owned replay. */ @@ -267,9 +291,11 @@ interface ResponseHandoffGate { targetSessionKey: string | null stoppedByUser: boolean acceptedTaskId: string + acceptedTaskStatus: string terminalResponse: boolean authoritativeIdle: boolean backgroundOnly: boolean + backgroundFinalized: boolean durableRecord: ResponseHandoffWalRecord | null } @@ -495,6 +521,19 @@ export interface UseChatSendOptions { runMode: Ref pendingAttachments: Ref composerRevision?: Readonly> + /** Invalidates a composer send when its message-edit owner is cancelled or replaced. */ + messageEditGeneration?: Readonly> + /** Whether a one-shot message-edit restore frame currently owns the composer. */ + messageEditActive?: Readonly> + /** Confirms that an edit still owns the exact live transcript before mutation. */ + validateMessageEditOwner?: (generation: number) => boolean + /** Retires the exact restore frame once Gateway acceptance commits the Edit. */ + commitMessageEdit?: (generation: number) => boolean + /** Extends edit ownership by the exact rows left by a definitely rejected send. */ + adoptRejectedMessageEditRows?: ( + generation: number, + rows: readonly ChatMessage[], + ) => boolean pendingSessionIntent: Ref initialCollaborationMode: Readonly> initialRoutingMode: Readonly> @@ -543,7 +582,7 @@ export interface UseChatSendOptions { sourceSessionKey: string, targetSessionKey: string, ownerRequestId: string, - ) => Promise + ) => Promise failPendingQueueHandoff?: (ownerRequestId: string) => Promise | void scheduleHistorySync: () => void schedulePendingDrainAfterTerminal: () => void @@ -602,6 +641,19 @@ export interface UseChatSendOptions { restoreSteerIntoComposer?: (text: string) => void popAllPendingIntoComposer: () => boolean reconcileTaskOwnership?: () => void | Promise + /** Bracket an older receipt replay so its early live events stay offscreen. */ + beginBackgroundReceiptReplay?: (clientMessageId: string, holdHistory?: boolean) => void + /** Keep a non-terminal accepted receipt task off the visible stream. */ + trackBackgroundReceiptTask?: ( + clientMessageId: string, + taskId: string, + terminal?: boolean | string, + allowProjection?: boolean, + retireParentProjection?: boolean, + acceptedStatus?: string, + ) => void + /** Release the pre-response event quarantine for an older receipt replay. */ + finishBackgroundReceiptReplay?: (clientMessageId: string) => void hiddenControlStorage?: HiddenControlStorage | null metaDiscardStorage?: MetaDiscardStorage | null classifySlashCommand: (text: string) => Promise @@ -623,6 +675,7 @@ export function useChatSend(options: UseChatSendOptions) { let activeResponseHandoff: ResponseHandoffGate | null = null let activeProjectPreflightToken: symbol | null = null let recoveredAttempt: SendAttempt | null = null + let composerOwnershipEpoch = 0 let usageBarrierReplayAttempt: SendAttempt | null = null let usageBarrierReplayInFlight = false let handoffRecoveryPromise: Promise | null = null @@ -682,6 +735,7 @@ export function useChatSend(options: UseChatSendOptions) { function acknowledgeAttemptPromptAnnotations( attempt: SendAttempt, response: TurnSendResponse, + updateVisibleMessage = true, ) { if ( attempt.promptAnnotationIds.length === 0 @@ -704,7 +758,7 @@ export function useChatSend(options: UseChatSendOptions) { const acceptedSnapshots = attempt.promptAnnotations.filter(snapshot => ( acceptedSet.has(snapshot.annotationId) )) - setAttemptPromptAnnotations(attempt, acceptedSnapshots) + if (updateVisibleMessage) setAttemptPromptAnnotations(attempt, acceptedSnapshots) // A first send from a provisional draft can be accepted under a different // canonical session key. Publish both identities so Workbench can finish // the native annotation lifecycle regardless of which descriptor wins the @@ -741,10 +795,11 @@ export function useChatSend(options: UseChatSendOptions) { if (messageIndex >= 0) { const message = options.messages.value[messageIndex] if (message) { - const next = { ...message } - if (snapshots.length > 0) next.promptAnnotations = [...snapshots] - else delete next.promptAnnotations - options.messages.value[messageIndex] = next + // Preserve the row identity. Message-edit cancellation owns exact + // transcript objects, and annotation retry bookkeeping must not make + // an otherwise untouched optimistic row look authoritatively replaced. + if (snapshots.length > 0) message.promptAnnotations = [...snapshots] + else delete message.promptAnnotations } } } @@ -776,6 +831,7 @@ export function useChatSend(options: UseChatSendOptions) { const queueOwnerContext = options.pendingQueueOwnerContext.value return { revision: options.composerRevision?.value ?? null, + ownershipEpoch: composerOwnershipEpoch, inputText: options.inputText.value, promptAnnotationIds: currentPromptAnnotationIds(), documentContext: normalizeDocumentContext( @@ -791,7 +847,206 @@ export function useChatSend(options: UseChatSendOptions) { queueOwnerRequestId: queueOwnerContext?.sessionKey === options.sessionKey.value ? queueOwnerContext.ownerRequestId : null, + messageEditActive: options.messageEditActive?.value === true, + messageEditGeneration: options.messageEditGeneration?.value ?? null, + } + } + + function recoveredAttemptHasUnrelatedComposer( + attempt: SendAttempt, + snapshot: ComposerSnapshot, + ): boolean { + const editOwner = attempt.messageEditTranscriptOwner + if (editOwner) { + const ownsTranscript = ( + options.messageEditActive?.value === true + && snapshot.messageEditGeneration === editOwner.generation + && attemptOwnsMessageEditTranscript(attempt) + ) + if (!ownsTranscript) return true + const recoveryOwner = attempt.recoveryComposerSnapshot + return !recoveryOwner || !sameComposerOwnershipSnapshot(snapshot, recoveryOwner) + } + const ownsFreshMaterial = Boolean( + snapshot.inputText + || snapshot.payloadAttachments.length > 0 + || snapshot.promptAnnotationIds.length > 0 + || snapshot.forkBeforeMessageId + || snapshot.intent + || snapshot.workspaceId, + ) + return ownsFreshMaterial + || !sameDocumentContext(snapshot.documentContext, attempt.documentContext) + || snapshot.initialCollaborationMode !== attempt.initialCollaborationMode + || snapshot.initialRoutingMode !== attempt.initialRoutingMode + } + + function quarantineRecoveredAttemptIfUnrelated() { + const attempt = recoveredAttempt + if ( + !attempt?.requiresIdempotentReplay + || attempt.requestSessionKey !== options.sessionKey.value + ) return + const snapshot = captureComposerSnapshot() + if (!recoveredAttemptHasUnrelatedComposer(attempt, snapshot)) return + options.beginBackgroundReceiptReplay?.( + attempt.clientMessageId, + options.messageEditActive?.value === true, + ) + } + + function sameComposerOwnershipSnapshot( + current: ComposerSnapshot, + owner: ComposerSnapshot, + ): boolean { + return ( + (owner.revision === null || current.revision === owner.revision) + && current.ownershipEpoch === owner.ownershipEpoch + && current.inputText === owner.inputText + && JSON.stringify(current.promptAnnotationIds) === JSON.stringify(owner.promptAnnotationIds) + && sameDocumentContext(current.documentContext, owner.documentContext) + && current.attachmentRefs.length === owner.attachmentRefs.length + && current.attachmentRefs.every( + (attachment, index) => attachment === owner.attachmentRefs[index], + ) + && JSON.stringify(current.payloadAttachments) === JSON.stringify(owner.payloadAttachments) + && current.intent === owner.intent + && current.forkBeforeMessageId === owner.forkBeforeMessageId + && current.workspaceId === owner.workspaceId + && current.initialCollaborationMode === owner.initialCollaborationMode + && current.initialRoutingMode === owner.initialRoutingMode + && current.messageEditActive === owner.messageEditActive + && current.messageEditGeneration === owner.messageEditGeneration + ) + } + + function rememberRecoveryComposerSnapshot(attempt: SendAttempt) { + if (attempt.recoveryComposerSnapshot) return + attempt.recoveryComposerSnapshot = captureComposerSnapshot() + } + + watch([ + () => options.sessionKey.value, + () => options.inputText.value, + () => options.pendingAttachments.value, + () => currentPromptAnnotationIds().join('\u0000'), + () => options.pendingForkBeforeMessageId.value, + () => options.pendingSessionIntent.value, + () => options.pendingWorkspaceId?.value, + () => options.messageEditGeneration?.value, + () => options.messageEditActive?.value, + () => options.initialCollaborationMode.value, + () => options.initialRoutingMode.value, + () => options.composerRevision?.value, + () => JSON.stringify(normalizeDocumentContext( + options.currentDocumentContext?.(options.sessionKey.value), + )), + ], () => { + composerOwnershipEpoch += 1 + quarantineRecoveredAttemptIfUnrelated() + }, { flush: 'sync', deep: true }) + + function messageEditOwnerMatchesSnapshot( + snapshot: ComposerSnapshot, + validateTranscript = false, + ): boolean { + if (!snapshot.messageEditActive) return true + if (snapshot.messageEditGeneration === null) return false + if (options.messageEditActive?.value !== true) return false + if (options.messageEditGeneration?.value !== snapshot.messageEditGeneration) return false + return !validateTranscript + || options.validateMessageEditOwner?.(snapshot.messageEditGeneration) !== false + } + + function forkSnapshotPreDispatchAllowed( + snapshot: ComposerSnapshot, + stage: 'preflight' | 'after_mutation' | 'before_rpc', + opts: { + forkBeforeMessageId?: string | null + allowAuthoritativeRecovery?: boolean + validateTranscript?: boolean + } = {}, + ): boolean { + if (!messageEditOwnerMatchesSnapshot( + snapshot, + stage === 'preflight' && opts.validateTranscript !== false, + )) return false + const forkBeforeMessageId = opts.forkBeforeMessageId === undefined + ? snapshot.forkBeforeMessageId + : opts.forkBeforeMessageId + if (!forkBeforeMessageId) return true + // `before_rpc` follows this dispatch's own beginFreshStream. Earlier + // stages must still reject a stream that appeared while async preparation + // was pending. Authoritative receipt recovery is the one exception: its + // existing work is the request this exact idempotent replay must resolve. + if ( + !opts.allowAuthoritativeRecovery + && stage !== 'before_rpc' + && options.stream.isStreaming.value + ) return false + if (!opts.allowAuthoritativeRecovery && hasAuthoritativeWork()) return false + return !options.isCompactInFlightForCurrentSession() + && !responseHandoffBlocksCurrentSession() + } + + function attemptOwnsMessageEditTranscript(attempt: SendAttempt): boolean { + const owner = attempt.messageEditTranscriptOwner + if (!owner) return true + const currentMessages = options.messages.value + return options.sessionKey.value === attempt.requestSessionKey + && options.messageEditGeneration?.value === owner.generation + && toRaw(currentMessages) === owner.messages + && currentMessages.length === owner.messageOwners.length + && currentMessages.every( + (message, index) => toRaw(message) === owner.messageOwners[index], + ) + } + + function attemptTranscriptIdentityStillOwned(attempt: SendAttempt): boolean { + const owner = attempt.messageEditTranscriptOwner + if (!owner) return true + const currentMessages = options.messages.value + return options.sessionKey.value === attempt.requestSessionKey + && toRaw(currentMessages) === owner.messages + && currentMessages.length === owner.messageOwners.length + && currentMessages.every( + (message, index) => toRaw(message) === owner.messageOwners[index], + ) + } + + function validateAttemptMessageEditTranscript(attempt: SendAttempt): boolean { + if (attemptOwnsMessageEditTranscript(attempt)) return true + if (recoveredAttempt === attempt) recoveredAttempt = null + const generation = attempt.messageEditTranscriptOwner?.generation + if ( + generation !== undefined + && options.messageEditGeneration?.value === generation + ) { + // Retire only the same stale generation. A newer edit owns its own stack + // and must not be disturbed by an old receipt replay. + options.validateMessageEditOwner?.(generation) } + return false + } + + function extendAttemptMessageEditTranscript( + attempt: SendAttempt, + rows: readonly ChatMessage[], + ): boolean { + const owner = attempt.messageEditTranscriptOwner + if (!owner || rows.length === 0) return false + const currentMessages = options.messages.value + const ownsPrefix = toRaw(currentMessages) === owner.messages + && currentMessages.length === owner.messageOwners.length + rows.length + && owner.messageOwners.every( + (message, index) => toRaw(currentMessages[index]) === message, + ) + const ownsSuffix = rows.every((message, index) => ( + toRaw(currentMessages[owner.messageOwners.length + index]) === toRaw(message) + )) + if (!ownsPrefix || !ownsSuffix) return false + owner.messageOwners.push(...rows.map(message => toRaw(message))) + return true } function queueOwnerMatchesSnapshot(snapshot: ComposerSnapshot): boolean { @@ -902,6 +1157,31 @@ export function useChatSend(options: UseChatSendOptions) { } } + function trackBackgroundReceiptResponse( + clientMessageId: string, + response: TurnSendResponse, + requestSessionKey: string, + ) { + const targetSessionKey = response.sessionKey || requestSessionKey + const requestSessionIsCurrent = requestSessionKey === options.sessionKey.value + const allowProjection = ( + targetSessionKey === requestSessionKey + && requestSessionIsCurrent + ) + const retireParentProjection = ( + requestSessionIsCurrent + && targetSessionKey !== requestSessionKey + ) + options.trackBackgroundReceiptTask?.( + clientMessageId, + acceptedTaskId(response), + terminalResponseStatus(response), + allowProjection, + retireParentProjection, + taskAcceptanceStatus(response), + ) + } + function supportsSameTurnSteer(): boolean { const capability = activeSteerCapability() const expectedTurnId = capabilityExpectedTurnId() @@ -969,6 +1249,12 @@ export function useChatSend(options: UseChatSendOptions) { return `${attempt.requestSessionKey}\u0000${attempt.clientRequestId}` } + function claimBackgroundRejectionContinuation(attempt: SendAttempt): boolean { + if (attempt.backgroundRejectionContinuationClaimed) return false + attempt.backgroundRejectionContinuationClaimed = true + return true + } + function beginFreshStream( requestSessionKey: string, attempt: SendAttempt | null = null, @@ -1098,11 +1384,27 @@ export function useChatSend(options: UseChatSendOptions) { async function settleRecoveredAcceptance( attempt: SendAttempt, response: TurnSendResponse, + forceBackground = false, ): Promise { - acknowledgeAttemptPromptAnnotations(attempt, response) + const acceptedSessionKey = response.sessionKey || attempt.requestSessionKey + let responseOwnsVisibleTranscript = !forceBackground + && !recoveredAttemptHasUnrelatedComposer(attempt, captureComposerSnapshot()) + && acceptedSessionKey === attempt.requestSessionKey + && validateAttemptMessageEditTranscript(attempt) + acknowledgeAttemptPromptAnnotations(attempt, response, responseOwnsVisibleTranscript) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(response) - attempt.acceptedSessionKey = response.sessionKey || attempt.requestSessionKey + attempt.acceptedTaskStatus = taskAcceptanceStatus(response) + attempt.acceptedSessionKey = acceptedSessionKey + if ( + responseOwnsVisibleTranscript + && attempt.messageEditTranscriptOwner + && options.commitMessageEdit?.( + attempt.messageEditTranscriptOwner.generation, + ) === false + ) { + responseOwnsVisibleTranscript = false + } const ownsRecoveredAttempt = recoveredAttempt?.clientRequestId === attempt.clientRequestId if (attempt.hiddenControl) { removeHiddenControl( @@ -1113,12 +1415,31 @@ export function useChatSend(options: UseChatSendOptions) { } const isCurrentRequest = options.sessionKey.value === attempt.requestSessionKey - const accepted = noteAcceptedTask(response, attempt.requestSessionKey) + const accepted = acceptedSessionKey === attempt.requestSessionKey + ? noteAcceptedTask(response, attempt.requestSessionKey) + : { taskId: acceptedTaskId(response), claimRender: false, renderTaskId: '' } const terminalStatus = terminalResponseStatus(response) - if (isCurrentRequest) { + if (isCurrentRequest && responseOwnsVisibleTranscript) { consumeAcceptedSessionIntent(attempt) bindAcceptedUserMessage(attempt.clientMessageId, response) options.scheduleHistorySync() + } else if (isCurrentRequest) { + consumeAcceptedSessionIntent(attempt) + } + if (!responseOwnsVisibleTranscript) { + trackBackgroundReceiptResponse( + attempt.clientMessageId, + response, + attempt.requestSessionKey, + ) + } + if ( + !responseOwnsVisibleTranscript + && attempt.forkBeforeMessageId + && !await finalizeAttemptBackgroundResponseHandoff(attempt, acceptedSessionKey) + ) { + attempt.acceptanceResolved = false + return false } if (!attempt.stopRequested) { @@ -1126,7 +1447,7 @@ export function useChatSend(options: UseChatSendOptions) { return true } if (terminalStatus) { - if (isCurrentRequest) { + if (isCurrentRequest && responseOwnsVisibleTranscript) { handleTerminalResponse(response, null, { finishFreshStream: false }) } clearAttemptStop(attempt) @@ -1145,10 +1466,52 @@ export function useChatSend(options: UseChatSendOptions) { return abortRecoveredAcceptedTask(attempt) } - function scheduleAcceptanceRecovery(attempt: SendAttempt) { - if ((attempt.acceptanceResolved && !attempt.stopRequested) || !attempt.acceptanceRequest) return + function attemptOwnsVisibleReceipt(attempt: SendAttempt): boolean { + return options.sessionKey.value === attempt.requestSessionKey + && !recoveredAttemptHasUnrelatedComposer(attempt, captureComposerSnapshot()) + && attemptOwnsMessageEditTranscript(attempt) + } + + async function persistAttemptBackgroundOnly(attempt: SendAttempt): Promise { + if (!attempt.forkBeforeMessageId) return true + const wal = options.pendingInputWal + if (!wal?.listHandoffs) return false + let records: ResponseHandoffWalRecord[] + try { + records = await wal.listHandoffs(attempt.requestSessionKey) + } catch { + return false + } + const record = records.find(candidate => ( + candidate.ownerRequestId === attempt.clientRequestId + && candidate.clientRequestId === attempt.clientRequestId + && candidate.clientMessageId === attempt.clientMessageId + )) + if (!record) return false + const gate: ResponseHandoffGate = { + requestSessionKey: attempt.requestSessionKey, + ownerRequestId: attempt.clientRequestId, + targetSessionKey: null, + stoppedByUser: attempt.stopRequested === true, + acceptedTaskId: attempt.acceptedTaskId || '', + acceptedTaskStatus: attempt.acceptedTaskStatus || '', + terminalResponse: false, + authoritativeIdle: false, + backgroundOnly: true, + backgroundFinalized: false, + durableRecord: record, + } + return markResponseHandoffBackgroundOnly(gate) + } + + function scheduleAcceptanceRecovery(attempt: SendAttempt): Promise | null { + if ( + (attempt.acceptanceResolved && !attempt.stopRequested) + || !attempt.acceptanceRequest + ) return null const key = acceptanceAttemptKey(attempt) - if (acceptanceRecoveryWorkers.has(key)) return + const existing = acceptanceRecoveryWorkers.get(key) + if (existing) return existing const operation = (async () => { let recoveryAttempt = 0 @@ -1158,29 +1521,73 @@ export function useChatSend(options: UseChatSendOptions) { ]! recoveryAttempt += 1 await new Promise(resolve => globalThis.setTimeout(resolve, delayMs)) + if (attempt.backgroundRejectionPending) { + if (!await retireAttemptBackgroundRejection( + attempt, + attempt.backgroundRejectionError, + )) continue + attempt.backgroundRejectionPending = false + attempt.backgroundRejectionError = undefined + attempt.acceptanceResolved = true + if (attempt.stopRequested) clearAttemptStop(attempt) + if (recoveredAttempt?.clientRequestId === attempt.clientRequestId) { + recoveredAttempt = null + } + return + } if (attempt.acceptanceResolved) { if (await abortRecoveredAcceptedTask(attempt)) return continue } if (attempt.acceptanceInFlight) continue attempt.acceptanceInFlight = true + let backgroundReceiptReplayStarted = false try { + const backgroundReceiptReplay = !attemptOwnsVisibleReceipt(attempt) + if (backgroundReceiptReplay) { + if (!await persistAttemptBackgroundOnly(attempt)) continue + options.beginBackgroundReceiptReplay?.( + attempt.clientMessageId, + options.messageEditActive?.value === true, + ) + backgroundReceiptReplayStarted = true + } const response = await options.turnCommands.send( attempt.acceptanceRequest!.request, ) - if (await settleRecoveredAcceptance(attempt, response)) return + if (await settleRecoveredAcceptance(attempt, response, backgroundReceiptReplay)) return } catch (error: unknown) { const rpcError = error as RpcClientError | null | undefined const accepted = acceptedErrorInfo(error) - if (rpcError?.accepted === false || accepted?.terminalWithoutTask) { + if (accepted) { + const response: TurnSendResponse = { + sessionKey: accepted.sessionKey || attempt.requestSessionKey, + userMessageId: accepted.messageId, + ...(accepted.terminalWithoutTask + ? { + taskStatus: 'failed', + terminalReason: errorMessage(error), + } + : {}), + } + if (await settleRecoveredAcceptance(attempt, response, backgroundReceiptReplayStarted)) { + return + } + continue + } + if (rpcError?.accepted === false) { + if (backgroundReceiptReplayStarted) { + attempt.backgroundRejectionPending = true + attempt.backgroundRejectionError = error + if (!await retireAttemptBackgroundRejection(attempt, error)) continue + attempt.backgroundRejectionPending = false + attempt.backgroundRejectionError = undefined + } attempt.acceptanceResolved = true if (attempt.stopRequested) clearAttemptStop(attempt) if ( attempt.hiddenControl - && ( - accepted?.terminalWithoutTask - || (rpcError?.accepted === false && rpcError.retryable === false) - ) + && rpcError.retryable === false ) { removeHiddenControl( attempt.requestSessionKey, @@ -1200,6 +1607,9 @@ export function useChatSend(options: UseChatSendOptions) { void options.reconcileTaskOwnership?.() } } finally { + if (backgroundReceiptReplayStarted) { + options.finishBackgroundReceiptReplay?.(attempt.clientMessageId) + } attempt.acceptanceInFlight = false } } @@ -1211,6 +1621,7 @@ export function useChatSend(options: UseChatSendOptions) { }) acceptanceRecoveryWorkers.set(key, operation) noteAcceptanceRecoveryChanged() + return operation } function pendingQueueOwner(): PendingQueueOwner | undefined { @@ -1256,9 +1667,11 @@ export function useChatSend(options: UseChatSendOptions) { targetSessionKey: null, stoppedByUser: false, acceptedTaskId: '', + acceptedTaskStatus: '', terminalResponse: false, authoritativeIdle: false, backgroundOnly: false, + backgroundFinalized: false, durableRecord, } activeResponseHandoff = gate @@ -1271,13 +1684,27 @@ export function useChatSend(options: UseChatSendOptions) { async function persistResponseHandoff( attempt: SendAttempt, requirePrepared = false, + preserveExistingOnFailure = false, ): Promise { const wal = options.pendingInputWal if (!wal) return null + if (preserveExistingOnFailure) { + if (!wal.listHandoffs) return null + const records = await wal.listHandoffs(attempt.requestSessionKey).catch(() => []) + return records.find(record => ( + record.ownerRequestId === attempt.clientRequestId + && record.clientRequestId === attempt.clientRequestId + && record.clientMessageId === attempt.clientMessageId + && record.state !== 'accepted' + && record.state !== 'failed' + && Boolean(record.walOwnerId && record.walRevision && wal.compareAndSwapHandoff) + )) || null + } if (requirePrepared && (!wal.prepareHandoff || !wal.compareAndSwapHandoff)) return null - if (!requirePrepared && !wal.putHandoff) return null + const useOwnedWal = Boolean(wal.prepareHandoff && wal.compareAndSwapHandoff) + if (!useOwnedWal && !wal.putHandoff) return null const now = Date.now() - if (requirePrepared) { + if (useOwnedWal) { attempt.handoffWalOwnerId ||= createClientRequestId() attempt.handoffWalRevision ||= 1 } @@ -1296,7 +1723,7 @@ export function useChatSend(options: UseChatSendOptions) { ...(attempt.replayCoordinationKey ? { replayCoordinationKey: attempt.replayCoordinationKey } : {}), - ...(requirePrepared + ...(useOwnedWal ? { walOwnerId: attempt.handoffWalOwnerId!, walRevision: attempt.handoffWalRevision!, @@ -1307,12 +1734,12 @@ export function useChatSend(options: UseChatSendOptions) { updatedAt: now, } try { - if (requirePrepared) { + if (useOwnedWal) { const prepared = await wal.prepareHandoff!(record) if (prepared.applied) return prepared.record const current = prepared.record if ( - current?.state === 'preparing' + current?.state === record.state && current.ownerRequestId === record.ownerRequestId && current.clientMessageId === record.clientMessageId && current.replayCoordinationKey === record.replayCoordinationKey @@ -1324,7 +1751,7 @@ export function useChatSend(options: UseChatSendOptions) { await wal.putHandoff!(record) return record } catch { - if (requirePrepared && record.walOwnerId && record.walRevision) { + if (useOwnedWal && record.walOwnerId && record.walRevision) { // The create operation may report failure after its write became // visible. A conditional delete cannot erase another tab's arm or // acceptance, and a failed delete leaves only an unarmed record. @@ -1334,7 +1761,7 @@ export function useChatSend(options: UseChatSendOptions) { record.walRevision, null, ).catch(() => {}) - } else { + } else if (!preserveExistingOnFailure) { await wal.deleteHandoff?.(record.ownerRequestId).catch(() => {}) } return null @@ -1454,16 +1881,62 @@ export function useChatSend(options: UseChatSendOptions) { } } + async function markResponseHandoffBackgroundOnly( + gate: ResponseHandoffGate, + ): Promise { + const current = gate.durableRecord + if (!current || current.backgroundOnly) return true + const backgroundRecord: ResponseHandoffWalRecord = { + ...current, + backgroundOnly: true, + ...(current.walRevision ? { walRevision: current.walRevision + 1 } : {}), + updatedAt: Date.now(), + } + if ( + current.walOwnerId + && current.walRevision + && options.pendingInputWal?.compareAndSwapHandoff + ) { + const transition = await options.pendingInputWal.compareAndSwapHandoff( + current.ownerRequestId, + current.walOwnerId, + current.walRevision, + backgroundRecord, + ).catch(() => null) + if (!transition?.applied || !transition.record) return false + gate.durableRecord = transition.record + return true + } + if (!options.pendingInputWal?.putHandoff) return false + try { + await options.pendingInputWal.putHandoff(backgroundRecord) + gate.durableRecord = backgroundRecord + return true + } catch { + return false + } + } + async function markResponseHandoffAccepted( gate: ResponseHandoffGate, acceptedSessionKey: string, - ): Promise { + ): Promise { const current = gate.durableRecord - if (!current) return + if (!current) return true + if ( + current.state === 'accepted' + && current.acceptedSessionKey === acceptedSessionKey + && (!gate.backgroundOnly || current.backgroundOnly) + && (!gate.acceptedTaskId || current.acceptedTaskId === gate.acceptedTaskId) + && (!gate.acceptedTaskStatus || current.acceptedTaskStatus === gate.acceptedTaskStatus) + ) return true const accepted: ResponseHandoffWalRecord = { ...current, state: 'accepted', acceptedSessionKey, + ...(gate.acceptedTaskId ? { acceptedTaskId: gate.acceptedTaskId } : {}), + ...(gate.acceptedTaskStatus ? { acceptedTaskStatus: gate.acceptedTaskStatus } : {}), + ...(gate.backgroundOnly ? { backgroundOnly: true } : {}), ...(current.walRevision ? { walRevision: current.walRevision + 1 } : {}), updatedAt: Date.now(), } @@ -1478,20 +1951,112 @@ export function useChatSend(options: UseChatSendOptions) { current.walRevision, accepted, ).catch(() => null) - if (transition?.applied && transition.record) gate.durableRecord = transition.record - return + if (!transition?.applied || !transition.record) return false + gate.durableRecord = transition.record + return true + } + if (!options.pendingInputWal?.putHandoff) return false + try { + await options.pendingInputWal.putHandoff(accepted) + gate.durableRecord = accepted + return true + } catch { + return false } - if (!options.pendingInputWal?.putHandoff) return - gate.durableRecord = accepted - await options.pendingInputWal.putHandoff(accepted).catch(() => {}) } - async function markResponseHandoffFailed( + async function finalizeBackgroundResponseHandoff( + gate: ResponseHandoffGate, + acceptedSessionKey: string, + ): Promise { + gate.targetSessionKey = acceptedSessionKey + gate.backgroundOnly = true + if (!await markResponseHandoffBackgroundOnly(gate)) return false + if (!await markResponseHandoffAccepted(gate, acceptedSessionKey)) return false + if (!gate.durableRecord) { + gate.backgroundFinalized = true + return true + } + if ( + options.sessionKey.value === gate.requestSessionKey + && !gate.stoppedByUser + && options.hasPendingQueueWork?.() !== true + ) { + // Arm the terminal signal while the handoff still blocks delivery. A + // later queue hydrate will flush it after the durable owner is released. + options.schedulePendingDrainAfterTerminal() + } + const queueReleased = await options.recoverPendingQueueHandoff?.( + gate.requestSessionKey, + gate.requestSessionKey, + gate.ownerRequestId, + ).catch(() => false) + if (queueReleased !== true) return false + if (gate.durableRecord) { + if (!await deleteResponseHandoff(gate.durableRecord)) return false + gate.durableRecord = null + } + gate.backgroundFinalized = true + return true + } + + function releaseBackgroundResponseHandoffParent(gate: ResponseHandoffGate): void { + if ( + !gate.backgroundOnly + || !gate.backgroundFinalized + || gate.stoppedByUser + || options.sessionKey.value !== gate.requestSessionKey + || options.stream.isStreaming.value + || options.taskOwnership?.hasAuthoritativeWork.value + ) return + options.flushDeferredPendingDrain() + if (options.hasPendingQueueWork?.() === true) { + options.schedulePendingDrainAfterTerminal() + } + } + + async function finalizeAttemptBackgroundResponseHandoff( + attempt: SendAttempt, + acceptedSessionKey: string, + ): Promise { + const wal = options.pendingInputWal + if (!wal?.listHandoffs) return false + let records: ResponseHandoffWalRecord[] + try { + records = await wal.listHandoffs(attempt.requestSessionKey) + } catch { + return false + } + const record = records.find(candidate => ( + candidate.ownerRequestId === attempt.clientRequestId + && candidate.clientRequestId === attempt.clientRequestId + && candidate.clientMessageId === attempt.clientMessageId + )) + if (!record) return true + const gate: ResponseHandoffGate = { + requestSessionKey: attempt.requestSessionKey, + ownerRequestId: attempt.clientRequestId, + targetSessionKey: acceptedSessionKey, + stoppedByUser: attempt.stopRequested === true, + acceptedTaskId: attempt.acceptedTaskId || '', + acceptedTaskStatus: attempt.acceptedTaskStatus || '', + terminalResponse: false, + authoritativeIdle: false, + backgroundOnly: true, + backgroundFinalized: false, + durableRecord: record, + } + const finalized = await finalizeBackgroundResponseHandoff(gate, acceptedSessionKey) + releaseBackgroundResponseHandoffParent(gate) + return finalized + } + + async function persistResponseHandoffFailed( gate: ResponseHandoffGate, error: unknown, - ): Promise { + ): Promise { const current = gate.durableRecord - if (!current) return + if (!current || current.state === 'failed') return true const failed: ResponseHandoffWalRecord = { ...current, state: 'failed', @@ -1510,14 +2075,90 @@ export function useChatSend(options: UseChatSendOptions) { current.walRevision, failed, ).catch(() => null) - if (transition?.applied && transition.record) gate.durableRecord = transition.record - } else if (options.pendingInputWal?.putHandoff) { + if (!transition?.applied || !transition.record) return false + gate.durableRecord = transition.record + return true + } + if (!options.pendingInputWal?.putHandoff) return false + try { + await options.pendingInputWal.putHandoff(failed) gate.durableRecord = failed - await options.pendingInputWal.putHandoff(failed).catch(() => {}) + return true + } catch { + return false } + } + + async function markResponseHandoffFailed( + gate: ResponseHandoffGate, + error: unknown, + ): Promise { + await persistResponseHandoffFailed(gate, error) await options.failPendingQueueHandoff?.(gate.ownerRequestId) } + async function retireRejectedBackgroundResponseHandoff( + gate: ResponseHandoffGate, + error: unknown, + ): Promise { + gate.backgroundOnly = true + if (!await persistResponseHandoffFailed(gate, error)) return false + if ( + options.sessionKey.value === gate.requestSessionKey + && options.hasPendingQueueWork?.() !== true + ) { + options.schedulePendingDrainAfterTerminal() + } + const queueReleased = await options.recoverPendingQueueHandoff?.( + gate.requestSessionKey, + gate.requestSessionKey, + gate.ownerRequestId, + ).catch(() => false) + if (queueReleased !== true) return false + if (gate.durableRecord) { + if (!await deleteResponseHandoff(gate.durableRecord)) return false + gate.durableRecord = null + } + gate.backgroundFinalized = true + return true + } + + async function retireAttemptBackgroundRejection( + attempt: SendAttempt, + error: unknown, + ): Promise { + const wal = options.pendingInputWal + if (!wal?.listHandoffs) return false + let records: ResponseHandoffWalRecord[] + try { + records = await wal.listHandoffs(attempt.requestSessionKey) + } catch { + return false + } + const record = records.find(candidate => ( + candidate.ownerRequestId === attempt.clientRequestId + && candidate.clientRequestId === attempt.clientRequestId + && candidate.clientMessageId === attempt.clientMessageId + )) + if (!record) return true + const gate: ResponseHandoffGate = { + requestSessionKey: attempt.requestSessionKey, + ownerRequestId: attempt.clientRequestId, + targetSessionKey: null, + stoppedByUser: attempt.stopRequested === true, + acceptedTaskId: attempt.acceptedTaskId || '', + acceptedTaskStatus: attempt.acceptedTaskStatus || '', + terminalResponse: false, + authoritativeIdle: false, + backgroundOnly: true, + backgroundFinalized: false, + durableRecord: record, + } + const retired = await retireRejectedBackgroundResponseHandoff(gate, error) + releaseBackgroundResponseHandoffParent(gate) + return retired + } + async function resetResponseHandoffForRetry( gate: ResponseHandoffGate, attempt: SendAttempt, @@ -1565,19 +2206,24 @@ export function useChatSend(options: UseChatSendOptions) { ownerRequestId: gate.ownerRequestId, } } - await markResponseHandoffAccepted(gate, key) - const adoption = key === gate.requestSessionKey && options.sessionKey.value === key - ? await options.recoverPendingQueueHandoff?.( - gate.requestSessionKey, - key, - gate.ownerRequestId, - ) - : await options.adoptResponseSession(key, gate.ownerRequestId) + if (!await markResponseHandoffAccepted(gate, key)) return + let adoption: Awaited> = undefined + if (key === gate.requestSessionKey && options.sessionKey.value === key) { + const recovered = await options.recoverPendingQueueHandoff?.( + gate.requestSessionKey, + key, + gate.ownerRequestId, + ) + if (recovered !== true) return + } else { + adoption = await options.adoptResponseSession(key, gate.ownerRequestId) + } if (gate.durableRecord && await deleteResponseHandoff(gate.durableRecord)) { gate.durableRecord = null } gate.authoritativeIdle = adoption?.authoritativeIdle === true gate.backgroundOnly = adoption?.backgroundOnly === true + gate.backgroundFinalized = gate.backgroundOnly if (gate.stoppedByUser && options.sessionKey.value === key) { options.activeStreamSessionKey.value = key if (gate.acceptedTaskId) { @@ -1626,6 +2272,7 @@ export function useChatSend(options: UseChatSendOptions) { if (options.pendingQueueOwnerContext.value?.ownerRequestId === gate.ownerRequestId) { options.pendingQueueOwnerContext.value = null } + releaseBackgroundResponseHandoffParent(gate) if (adoptedTargetIsCurrent && !gate.stoppedByUser) { options.flushDeferredPendingDrain() // An idle subscription snapshot can be authoritative without replaying @@ -1686,14 +2333,155 @@ export function useChatSend(options: UseChatSendOptions) { } else { await options.pendingInputWal?.putHandoff?.(acceptedRecord).catch(() => {}) } - await options.recoverPendingQueueHandoff?.( + const queueReleased = await options.recoverPendingQueueHandoff?.( record.requestSessionKey, targetSessionKey, record.ownerRequestId, - ) + ).catch(() => false) + if (queueReleased !== true) return await deleteResponseHandoff(acceptedRecord) } + async function finalizeRecoveredBackgroundHandoff( + record: ResponseHandoffWalRecord, + targetSessionKey: string, + acceptedTaskId = record.acceptedTaskId || '', + acceptedTaskStatus = record.acceptedTaskStatus || '', + ): Promise { + const gate = beginResponseHandoff( + record.requestSessionKey, + record.ownerRequestId, + record, + ) + gate.backgroundOnly = true + gate.acceptedTaskId = acceptedTaskId + gate.acceptedTaskStatus = acceptedTaskStatus + gate.terminalResponse = Boolean(terminalResponseStatus({ taskStatus: acceptedTaskStatus })) + try { + return await finalizeBackgroundResponseHandoff(gate, targetSessionKey) + } finally { + finishResponseHandoff(gate) + } + } + + async function finalizeRecoveredBackgroundHandoffUntilComplete( + initialRecord: ResponseHandoffWalRecord, + initialTargetSessionKey: string, + acceptedTaskId = initialRecord.acceptedTaskId || '', + acceptedTaskStatus = initialRecord.acceptedTaskStatus || '', + ): Promise { + const wal = options.pendingInputWal + if (!wal?.listHandoffs) return + let record = initialRecord + let targetSessionKey = initialTargetSessionKey + let retryAttempt = 0 + while (true) { + const finalized = await finalizeRecoveredBackgroundHandoff( + record, + targetSessionKey, + acceptedTaskId, + acceptedTaskStatus, + ) + if (finalized) return + const delayMs = acceptanceRecoveryDelaysMs[ + Math.min(retryAttempt, acceptanceRecoveryDelaysMs.length - 1) + ]! + retryAttempt += 1 + await new Promise(resolve => globalThis.setTimeout(resolve, delayMs)) + let records: ResponseHandoffWalRecord[] + try { + records = await wal.listHandoffs(record.requestSessionKey) + } catch { + continue + } + const current = records.find(candidate => ( + candidate.ownerRequestId === record.ownerRequestId + && candidate.clientRequestId === record.clientRequestId + && candidate.clientMessageId === record.clientMessageId + )) + if (!current) return + if (current.state === 'failed') { + await retireBackgroundHandoffUntilComplete(current) + return + } + if (current.state === 'preparing' || current.backgroundOnly !== true) return + record = current + targetSessionKey = current.acceptedSessionKey || targetSessionKey + acceptedTaskId = current.acceptedTaskId || acceptedTaskId + acceptedTaskStatus = current.acceptedTaskStatus || acceptedTaskStatus + } + } + + async function retireFailedBackgroundHandoff( + record: ResponseHandoffWalRecord, + rejectionError?: unknown, + ): Promise { + const gate = beginResponseHandoff( + record.requestSessionKey, + record.ownerRequestId, + record, + ) + gate.backgroundOnly = true + try { + if (rejectionError !== undefined) { + return retireRejectedBackgroundResponseHandoff(gate, rejectionError) + } + if ( + options.sessionKey.value === gate.requestSessionKey + && options.hasPendingQueueWork?.() !== true + ) { + options.schedulePendingDrainAfterTerminal() + } + const queueReleased = await options.recoverPendingQueueHandoff?.( + gate.requestSessionKey, + gate.requestSessionKey, + gate.ownerRequestId, + ).catch(() => false) + if (queueReleased !== true) return false + if (!await deleteResponseHandoff(record)) return false + gate.durableRecord = null + gate.backgroundFinalized = true + return true + } finally { + finishResponseHandoff(gate) + } + } + + async function retireBackgroundHandoffUntilComplete( + initialRecord: ResponseHandoffWalRecord, + rejectionError?: unknown, + ): Promise { + const wal = options.pendingInputWal + if (!wal?.listHandoffs) return + let record = initialRecord + let retryAttempt = 0 + while (true) { + const retired = await retireFailedBackgroundHandoff( + record, + record.state === 'failed' ? undefined : rejectionError, + ) + if (retired) return + const delayMs = acceptanceRecoveryDelaysMs[ + Math.min(retryAttempt, acceptanceRecoveryDelaysMs.length - 1) + ]! + retryAttempt += 1 + await new Promise(resolve => globalThis.setTimeout(resolve, delayMs)) + let records: ResponseHandoffWalRecord[] + try { + records = await wal.listHandoffs(record.requestSessionKey) + } catch { + continue + } + const current = records.find(candidate => ( + candidate.ownerRequestId === record.ownerRequestId + && candidate.clientRequestId === record.clientRequestId + && candidate.clientMessageId === record.clientMessageId + )) + if (!current) return + record = current + } + } + function restoreResponseHandoffDraft(record: ResponseHandoffWalRecord): boolean { if (options.sessionKey.value !== record.requestSessionKey) return false if (record.restoreComposerOnFailure === false) return true @@ -1730,6 +2518,12 @@ export function useChatSend(options: UseChatSendOptions) { function recoverResponseHandoffs(): Promise { if (handoffRecoveryPromise) return handoffRecoveryPromise + let backgroundReceiptClientMessageId = '' + const finishBackgroundReceiptRecovery = () => { + const clientMessageId = backgroundReceiptClientMessageId + backgroundReceiptClientMessageId = '' + if (clientMessageId) options.finishBackgroundReceiptReplay?.(clientMessageId) + } const operation = (async () => { const wal = options.pendingInputWal if (!wal?.listHandoffs || activeResponseHandoff) return @@ -1752,15 +2546,46 @@ export function useChatSend(options: UseChatSendOptions) { continue } if (record.state === 'failed') { - if (restoreResponseHandoffDraft(record)) { + if (record.backgroundOnly) { + await retireBackgroundHandoffUntilComplete(record) + } else if (restoreResponseHandoffDraft(record)) { await deleteResponseHandoff(record) } continue } if (record.state === 'accepted' && record.acceptedSessionKey) { - await finalizeRecoveredHandoff(record, record.acceptedSessionKey) + if (record.backgroundOnly) { + options.beginBackgroundReceiptReplay?.( + record.clientMessageId, + options.messageEditActive?.value === true, + ) + backgroundReceiptClientMessageId = record.clientMessageId + trackBackgroundReceiptResponse( + record.clientMessageId, + { + sessionKey: record.acceptedSessionKey, + taskId: record.acceptedTaskId, + taskStatus: record.acceptedTaskStatus, + }, + record.requestSessionKey, + ) + await finalizeRecoveredBackgroundHandoffUntilComplete( + record, + record.acceptedSessionKey, + ) + finishBackgroundReceiptRecovery() + } else { + await finalizeRecoveredHandoff(record, record.acceptedSessionKey) + } continue } + if (record.backgroundOnly) { + options.beginBackgroundReceiptReplay?.( + record.clientMessageId, + options.messageEditActive?.value === true, + ) + backgroundReceiptClientMessageId = record.clientMessageId + } let replayRecord = record let refreshedExpiredAttachments = false while (true) { @@ -1770,12 +2595,33 @@ export function useChatSend(options: UseChatSendOptions) { params: replayRecord.params, }) const targetSessionKey = response.sessionKey || replayRecord.requestSessionKey - await finalizeRecoveredHandoff(replayRecord, targetSessionKey) + if (replayRecord.backgroundOnly) { + trackBackgroundReceiptResponse( + replayRecord.clientMessageId, + response, + replayRecord.requestSessionKey, + ) + await finalizeRecoveredBackgroundHandoffUntilComplete( + replayRecord, + targetSessionKey, + acceptedTaskId(response), + taskAcceptanceStatus(response), + ) + } else { + await finalizeRecoveredHandoff(replayRecord, targetSessionKey) + } break } catch (error) { const accepted = acceptedErrorInfo(error) if (accepted?.sessionKey) { - await finalizeRecoveredHandoff(replayRecord, accepted.sessionKey) + if (replayRecord.backgroundOnly) { + await finalizeRecoveredBackgroundHandoffUntilComplete( + replayRecord, + accepted.sessionKey, + ) + } else { + await finalizeRecoveredHandoff(replayRecord, accepted.sessionKey) + } break } const rpcError = error as RpcClientError | null | undefined @@ -1819,7 +2665,9 @@ export function useChatSend(options: UseChatSendOptions) { continue } } - if (definitelyRejected && rpcError?.retryable === false) { + if (definitelyRejected && replayRecord.backgroundOnly) { + await retireBackgroundHandoffUntilComplete(replayRecord, error) + } else if (definitelyRejected && rpcError?.retryable === false) { await wal.putHandoff?.({ ...replayRecord, state: 'failed', @@ -1832,13 +2680,15 @@ export function useChatSend(options: UseChatSendOptions) { { tone: 'danger' }, ) } - // Unknown/retryable acceptance deliberately remains submitting - // and is replayed byte-for-byte after the next reconnect. + // Unknown acceptance, and retryable foreground rejection, + // remain submitting for the next reconnect. break } } + finishBackgroundReceiptRecovery() } })().finally(() => { + finishBackgroundReceiptRecovery() handoffRecoveryPromise = null }) handoffRecoveryPromise = operation @@ -1892,7 +2742,10 @@ export function useChatSend(options: UseChatSendOptions) { if (index < 0) return const optimistic = options.messages.value[index] if (!optimistic || optimistic.messageId === messageId) return - options.messages.value[index] = { ...optimistic, messageId } + // Keep the exact optimistic row identity. Message-edit retry ownership is + // intentionally identity-based so a server-id acknowledgement must not + // look like an authoritative transcript replacement. + optimistic.messageId = messageId } function bindAcceptedTask(taskId: string) { @@ -2283,19 +3136,125 @@ export function useChatSend(options: UseChatSendOptions) { const replayBlockedReason = options.idempotentReplayBlockedReason || options.sendBlockedReason if (replayBlockedReason?.value) return + if (exactReplayAttempt.backgroundRejectionPending) { + const pendingRejectionSnapshot = captureComposerSnapshot() + const replayingSupersededEditOwner = Boolean( + recoveredAttemptHasUnrelatedComposer( + exactReplayAttempt, + pendingRejectionSnapshot, + ) + && pendingRejectionSnapshot.messageEditActive + && pendingRejectionSnapshot.messageEditGeneration !== null + && exactReplayAttempt.messageEditTranscriptOwner + && pendingRejectionSnapshot.messageEditGeneration + !== exactReplayAttempt.messageEditTranscriptOwner.generation + ) + if ( + !replayingSupersededEditOwner + || !messageEditOwnerMatchesSnapshot(pendingRejectionSnapshot, true) + ) return + const rejectionRetirement = scheduleAcceptanceRecovery(exactReplayAttempt) + if (rejectionRetirement) await rejectionRetirement + if ( + exactReplayAttempt.backgroundRejectionPending + || !exactReplayAttempt.acceptanceResolved + || options.sessionKey.value !== requestSessionKey + || !sameComposerOwnershipSnapshot( + captureComposerSnapshot(), + pendingRejectionSnapshot, + ) + || !messageEditOwnerMatchesSnapshot(pendingRejectionSnapshot, true) + ) return + if (!claimBackgroundRejectionContinuation(exactReplayAttempt)) return + return onSend(invocation) + } if (options.validateActiveProjectBeforeSend) { if (await refreshedActiveProjectBlocksSend()) return } if (options.sessionKey.value !== requestSessionKey) return + const replayComposerSnapshot = captureComposerSnapshot() + const preserveUnrelatedBranch = recoveredAttemptHasUnrelatedComposer( + exactReplayAttempt, + replayComposerSnapshot, + ) + const replayingSupersededEditOwner = Boolean( + preserveUnrelatedBranch + && replayComposerSnapshot.messageEditActive + && replayComposerSnapshot.messageEditGeneration !== null + && exactReplayAttempt.messageEditTranscriptOwner + && replayComposerSnapshot.messageEditGeneration + !== exactReplayAttempt.messageEditTranscriptOwner.generation, + ) + if (!messageEditOwnerMatchesSnapshot(replayComposerSnapshot)) return + if ( + !replayingSupersededEditOwner + && !validateAttemptMessageEditTranscript(exactReplayAttempt) + ) return if (replayBlockedReason?.value) return - await dispatchSend(exactReplayAttempt.text, { + let rejectedReplayRetired = false + let rejectedReplayRetirement: Promise | null = null + let waitedForRejectionRetirement = false + const replayOutcome = await dispatchSend(exactReplayAttempt.text, { composerText, + composerSnapshot: replayComposerSnapshot, promptAnnotationIds: exactReplayAttempt.promptAnnotationIds, queueMode: exactReplayAttempt.queueMode, + payload: { + attachments: exactReplayAttempt.attachments, + intent: exactReplayAttempt.intent, + forkBeforeMessageId: exactReplayAttempt.forkBeforeMessageId, + workspaceId: exactReplayAttempt.workspaceId, + initialCollaborationMode: exactReplayAttempt.initialCollaborationMode, + documentContext: exactReplayAttempt.documentContext, + initialRoutingMode: exactReplayAttempt.initialRoutingMode, + }, retryAttempt: exactReplayAttempt, idempotentReplay: true, + // Receipt recovery resolves the prior immutable request. If the user + // has since entered another branch operation, none of its composer or + // visible transcript state belongs to the replay. + preserveComposer: preserveUnrelatedBranch, + suppressRejectedFailureMessage: preserveUnrelatedBranch, + backgroundReceiptReplay: preserveUnrelatedBranch, + onBackgroundRejectionRetired: () => { + rejectedReplayRetired = true + }, + onBackgroundRejectionRetirement: (retirement) => { + rejectedReplayRetirement = retirement + }, + preDispatchGuard: stage => ( + forkSnapshotPreDispatchAllowed( + replayComposerSnapshot, + stage, + { + forkBeforeMessageId: exactReplayAttempt.forkBeforeMessageId, + allowAuthoritativeRecovery: true, + // The first unknown attempt already installed its optimistic row. + // The attempt's exact transcript lease below owns that mutation. + validateTranscript: false, + }, + ) + && ( + replayingSupersededEditOwner + || validateAttemptMessageEditTranscript(exactReplayAttempt) + ) + ), }) - return + if (!replayingSupersededEditOwner) return + if (replayOutcome !== 'accepted' && !rejectedReplayRetired) { + if (!rejectedReplayRetirement) return + await rejectedReplayRetirement + waitedForRejectionRetirement = true + } + if ( + options.sessionKey.value !== requestSessionKey + || !sameComposerOwnershipSnapshot(captureComposerSnapshot(), replayComposerSnapshot) + || !messageEditOwnerMatchesSnapshot(replayComposerSnapshot, true) + ) return + if ( + waitedForRejectionRetirement + && !claimBackgroundRejectionContinuation(exactReplayAttempt) + ) return } if (hasPayload) { @@ -2308,6 +3267,7 @@ export function useChatSend(options: UseChatSendOptions) { if (await refreshedActiveProjectBlocksSend()) return } if (options.sessionKey.value !== requestSessionKey) return + if (!forkSnapshotPreDispatchAllowed(composerSnapshot, 'preflight')) return if (!queueOwnerMatchesSnapshot(composerSnapshot)) return if (options.sendBlockedReason?.value) return if ( @@ -2350,6 +3310,10 @@ export function useChatSend(options: UseChatSendOptions) { payload: payloadFromSnapshot(composerSnapshot), composerSnapshot, cancelIfComposerChanged: invocation.cancelIfComposerChanged, + preDispatchGuard: stage => forkSnapshotPreDispatchAllowed( + composerSnapshot, + stage, + ), }) return } @@ -2362,6 +3326,7 @@ export function useChatSend(options: UseChatSendOptions) { if (slashClassification !== null) { if ( options.sessionKey.value !== requestSessionKey + || !forkSnapshotPreDispatchAllowed(composerSnapshot, 'preflight') || !composerMatchesSnapshot(composerSnapshot) || !queueOwnerMatchesSnapshot(composerSnapshot) || Boolean(options.sendBlockedReason?.value) @@ -2373,6 +3338,7 @@ export function useChatSend(options: UseChatSendOptions) { ) return if ( options.sessionKey.value !== requestSessionKey + || !forkSnapshotPreDispatchAllowed(composerSnapshot, 'preflight') || !composerMatchesSnapshot(composerSnapshot) || !queueOwnerMatchesSnapshot(composerSnapshot) || Boolean(options.sendBlockedReason?.value) @@ -2427,6 +3393,12 @@ export function useChatSend(options: UseChatSendOptions) { await dispatchSteerV2(text, { composerSnapshot }) return } + // A queued item has no fork anchor, so treating a message edit as an + // ordinary follow-up would silently change its meaning. It would also + // let async queue persistence outlive Escape and deliver after the + // transcript was restored. Keep the edit in the composer until the + // authoritative run settles, when it can use the normal fork send. + if (composerSnapshot.forkBeforeMessageId) return // Surface a full queue instead of silently dropping the send: the draft is // preserved (enqueue returns false before clearing the composer). const composerChanged = !composerMatchesSnapshot(composerSnapshot) @@ -2488,6 +3460,10 @@ export function useChatSend(options: UseChatSendOptions) { payload: payloadFromSnapshot(composerSnapshot), composerSnapshot, cancelIfComposerChanged: invocation.cancelIfComposerChanged, + preDispatchGuard: stage => forkSnapshotPreDispatchAllowed( + composerSnapshot, + stage, + ), }) } @@ -2695,7 +3671,7 @@ export function useChatSend(options: UseChatSendOptions) { : options.sendBlockedReason if (blockedReason?.value) return 'not_sent' const preDispatchAllowed = ( - stage: 'preflight' | 'before_rpc' = 'preflight', + stage: 'preflight' | 'after_mutation' | 'before_rpc' = 'preflight', ) => sendOpts.preDispatchGuard?.(stage) !== false if (!preDispatchAllowed()) return 'not_sent' let preserveComposer = sendOpts.preserveComposer === true @@ -2734,6 +3710,15 @@ export function useChatSend(options: UseChatSendOptions) { // stream state, and chat.send. A blocked draft remains exactly editable. if (modelImageSendBlocked(sourceAttachments)) return 'not_sent' const retryCandidate = sendOpts.retryAttempt ?? (preserveComposer ? null : recoveredAttempt) + const explicitBackgroundReceiptReplay = Boolean( + retryCandidate + && sendOpts.retryAttempt === retryCandidate + && sendOpts.idempotentReplay + && sendOpts.backgroundReceiptReplay, + ) + const retryCandidateOwnsTranscript = !retryCandidate + || attemptOwnsMessageEditTranscript(retryCandidate) + || explicitBackgroundReceiptReplay const requestedPromptAnnotationIds = sendOpts.promptAnnotationIds === undefined ? currentPromptAnnotationIds() : [...sendOpts.promptAnnotationIds] @@ -2742,6 +3727,7 @@ export function useChatSend(options: UseChatSendOptions) { .slice(0, 16) const requiresRecoveryReplay = Boolean( retryCandidate?.requiresIdempotentReplay + && retryCandidateOwnsTranscript && retryCandidate.requestSessionKey === requestSessionKey && retryCandidate.queueMode === sendOpts.queueMode, ) @@ -2749,6 +3735,7 @@ export function useChatSend(options: UseChatSendOptions) { requiresRecoveryReplay || ( retryCandidate + && retryCandidateOwnsTranscript && matchesRecoveredDraft(retryCandidate, { requestSessionKey, promptAnnotationIds: requestedPromptAnnotationIds, @@ -2771,7 +3758,7 @@ export function useChatSend(options: UseChatSendOptions) { if (retryAttempt?.acceptanceInFlight) return 'retryable_failure' const attemptPromptAnnotationIds = retryAttempt?.promptAnnotationIds ?? requestedPromptAnnotationIds - if (promptAnnotationSendIsBusy(attemptPromptAnnotationIds)) { + if (!sendOpts.idempotentReplay && promptAnnotationSendIsBusy(attemptPromptAnnotationIds)) { rejectBusyPromptAnnotationSend() return 'not_sent' } @@ -2788,6 +3775,7 @@ export function useChatSend(options: UseChatSendOptions) { { isCurrent: () => options.sessionKey.value === requestSessionKey }, ) if (!ready || options.sessionKey.value !== requestSessionKey) return 'not_sent' + if (!preDispatchAllowed()) return 'not_sent' if (options.sendBlockedReason?.value) return 'not_sent' if ( JSON.stringify(currentPromptAnnotationIds()) @@ -2850,6 +3838,12 @@ export function useChatSend(options: UseChatSendOptions) { : false if (sendOpts.cancelIfComposerChanged && composerChanged) return 'not_sent' if (composerChanged) preserveComposer = true + let backgroundReceiptReplay = sendOpts.backgroundReceiptReplay === true + || Boolean( + sendOpts.idempotentReplay + && preserveComposer + && retryAttempt?.requiresIdempotentReplay, + ) const currentSourceAttachments = sendOpts.payload?.attachments ?? options.pendingAttachments.value if ( @@ -2882,6 +3876,34 @@ export function useChatSend(options: UseChatSendOptions) { const userText = text let attempt = retryAttempt + let appendedOptimisticMessage: ChatMessage | null = null + const adoptDefinitelyRejectedEditRows = (errorRow: ChatMessage): void => { + const owner = attempt?.messageEditTranscriptOwner + const generation = sendOpts.composerSnapshot?.messageEditGeneration + ?? owner?.generation + if ( + generation === null + || generation === undefined + || !attempt?.forkBeforeMessageId + || !options.adoptRejectedMessageEditRows + ) return + if ( + owner + && ( + owner.cancelableMessageCount < owner.baseMessageCount + || owner.cancelableMessageCount > owner.messageOwners.length + ) + ) return + const rows = owner + ? owner.messageOwners.slice(owner.cancelableMessageCount) + : appendedOptimisticMessage + ? [appendedOptimisticMessage, errorRow] + : [errorRow] + if (rows.length === 0) return + if (options.adoptRejectedMessageEditRows(generation, rows) && owner) { + owner.cancelableMessageCount = owner.messageOwners.length + } + } let acceptedVisibleReplayCommitted = false const commitAcceptedVisibleReplay = (accepted?: { messageId?: string @@ -2909,11 +3931,16 @@ export function useChatSend(options: UseChatSendOptions) { return true } let durableHandoffRecord: ResponseHandoffWalRecord | null = null + const preservesExistingReplayHandoff = Boolean( + sendOpts.idempotentReplay && retryAttempt?.requiresIdempotentReplay, + ) const rejectBeforeDispatch = async (): Promise => { if (attempt && sendOpts.acceptedVisibleReplay) { sendOpts.rememberRetryableAttempt?.(attempt) } - await discardUnsentResponseHandoff(durableHandoffRecord) + if (!preservesExistingReplayHandoff) { + await discardUnsentResponseHandoff(durableHandoffRecord) + } return 'not_sent' } if (!attempt) { @@ -2975,6 +4002,20 @@ export function useChatSend(options: UseChatSendOptions) { ...(sendOpts.replayCoordination ? { replayCoordinationKey: sendOpts.replayCoordination.key } : {}), + ...( + forkBeforeMessageId + && sendOpts.composerSnapshot?.messageEditActive === true + && sendOpts.composerSnapshot.messageEditGeneration != null + ? { + messageEditTranscriptOwner: { + generation: sendOpts.composerSnapshot.messageEditGeneration, + messages: toRaw(options.messages.value), + messageOwners: options.messages.value.map(message => toRaw(message)), + baseMessageCount: options.messages.value.length, + cancelableMessageCount: options.messages.value.length, + }, + } + : {}), params, } if (attempt.forkBeforeMessageId) { @@ -2990,7 +4031,7 @@ export function useChatSend(options: UseChatSendOptions) { if (!sendOpts.acceptedVisibleReplay) { const now = new Date().toISOString() const displayAttachments = attachmentsToSend.map(serializeDisplayAttachment) - options.messages.value.push({ + const optimisticMessage: ChatMessage = { role: 'user', text: userText, ts: now, @@ -2999,7 +4040,10 @@ export function useChatSend(options: UseChatSendOptions) { ...(attempt.promptAnnotations.length > 0 ? { promptAnnotations: attempt.promptAnnotations } : {}), - }) + } + options.messages.value.push(optimisticMessage) + appendedOptimisticMessage = optimisticMessage + extendAttemptMessageEditTranscript(attempt, [optimisticMessage]) options.autoScroll.value = true options.scrollToBottom() } @@ -3008,13 +4052,29 @@ export function useChatSend(options: UseChatSendOptions) { durableHandoffRecord = await persistResponseHandoff( attempt, sendOpts.requirePreparedHandoff, + preservesExistingReplayHandoff, ) - if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { + if ( + (sendOpts.requirePreparedHandoff || preservesExistingReplayHandoff) + && !durableHandoffRecord + ) { return rejectBeforeDispatch() } if (!preDispatchAllowed()) return rejectBeforeDispatch() } - if (!preDispatchAllowed()) return rejectBeforeDispatch() + if ( + sendOpts.idempotentReplay + && retryAttempt?.requiresIdempotentReplay + && sendOpts.composerSnapshot + && !sameComposerOwnershipSnapshot( + captureComposerSnapshot(), + sendOpts.composerSnapshot, + ) + ) { + preserveComposer = true + backgroundReceiptReplay = true + } + if (!preDispatchAllowed('after_mutation')) return rejectBeforeDispatch() if (!preserveComposer) options.closeSlashMenu() recordSessionNavigationDiag('send.start', { requestSession: requestSessionKey, @@ -3034,7 +4094,7 @@ export function useChatSend(options: UseChatSendOptions) { if (options.pendingForkBeforeMessageId.value === forkBeforeMessageId) { options.pendingForkBeforeMessageId.value = null } - } else if (sendOpts.composerSnapshot) { + } else if (sendOpts.composerSnapshot && !backgroundReceiptReplay) { const originalAttachmentRefs = new Set(sendOpts.composerSnapshot.attachmentRefs) options.pendingAttachments.value = options.pendingAttachments.value.filter( attachment => !originalAttachmentRefs.has(attachment), @@ -3043,11 +4103,11 @@ export function useChatSend(options: UseChatSendOptions) { // A steer send rides an already-active stream; restarting it would wipe // the partial output of the run being steered. const wasStreaming = options.stream.isStreaming.value - if (!preDispatchAllowed()) return rejectBeforeDispatch() - const freshSendToken = wasStreaming + if (!preDispatchAllowed('after_mutation')) return rejectBeforeDispatch() + const freshSendToken = wasStreaming || backgroundReceiptReplay ? null : beginFreshStream(requestSessionKey, attempt) - if (!preDispatchAllowed('before_rpc')) { + if (!preDispatchAllowed(freshSendToken ? 'before_rpc' : 'after_mutation')) { if (freshSendToken && activeFreshSendToken === freshSendToken) { activeFreshSendToken = null options.activeStreamTaskId.value = '' @@ -3068,7 +4128,7 @@ export function useChatSend(options: UseChatSendOptions) { return rejectBeforeDispatch() } durableHandoffRecord = armed - if (!preDispatchAllowed('before_rpc')) { + if (!preDispatchAllowed(freshSendToken ? 'before_rpc' : 'after_mutation')) { durableHandoffRecord = await disarmResponseHandoff(armed, attempt) || armed if (freshSendToken && activeFreshSendToken === freshSendToken) { activeFreshSendToken = null @@ -3101,6 +4161,7 @@ export function useChatSend(options: UseChatSendOptions) { // pending cards without creating a duplicate message. setAttemptPromptAnnotations(attempt, attempt.promptAnnotations) + let backgroundRejectionRetired = false try { const stagedPendingItem = serverStagedPendingItem const acceptanceRequest = attempt.acceptanceRequest?.request || ( @@ -3120,12 +4181,80 @@ export function useChatSend(options: UseChatSendOptions) { } ) attempt.acceptanceRequest = { request: acceptanceRequest } + if (backgroundReceiptReplay && responseHandoff) { + responseHandoff.backgroundOnly = true + if (!await markResponseHandoffBackgroundOnly(responseHandoff)) { + return rejectBeforeDispatch() + } + durableHandoffRecord = responseHandoff.durableRecord + } attempt.acceptanceInFlight = true + rememberRecoveryComposerSnapshot(attempt) + if (backgroundReceiptReplay) { + options.beginBackgroundReceiptReplay?.( + attempt.clientMessageId, + options.messageEditActive?.value === true, + ) + } const res = await options.turnCommands.send(acceptanceRequest) - acknowledgeAttemptPromptAnnotations(attempt, res) + let responseOwnsVisibleTranscript = !backgroundReceiptReplay + && validateAttemptMessageEditTranscript(attempt) + acknowledgeAttemptPromptAnnotations(attempt, res, responseOwnsVisibleTranscript) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(res) + attempt.acceptedTaskStatus = taskAcceptanceStatus(res) attempt.acceptedSessionKey = res?.sessionKey || requestSessionKey + if ( + responseOwnsVisibleTranscript + && attempt.messageEditTranscriptOwner + && options.commitMessageEdit?.( + attempt.messageEditTranscriptOwner.generation, + ) === false + ) { + responseOwnsVisibleTranscript = false + } + if (!responseOwnsVisibleTranscript) { + const acceptedSessionKey = res?.sessionKey || requestSessionKey + const terminalStatus = terminalResponseStatus(res) + const taskId = acceptedTaskId(res) + // A fork receipt belongs to its child session. Never let an offscreen + // child claim the still-visible parent's busy/Stop ownership. + if (acceptedSessionKey === requestSessionKey) { + noteAcceptedTask(res, requestSessionKey) + } + trackBackgroundReceiptResponse(attempt.clientMessageId, res, requestSessionKey) + if (responseHandoff) { + responseHandoff.acceptedTaskId = taskId + responseHandoff.acceptedTaskStatus = taskAcceptanceStatus(res) + responseHandoff.terminalResponse = Boolean(terminalStatus) + const finalized = await finalizeBackgroundResponseHandoff( + responseHandoff, + acceptedSessionKey, + ) + if (!finalized) { + attempt.acceptanceResolved = false + recoveredAttempt = attempt + scheduleAcceptanceRecovery(attempt) + } + } + if ( + attempt.acceptanceResolved + && recoveredAttempt?.clientRequestId === attempt.clientRequestId + ) { + recoveredAttempt = null + } + consumeAcceptedSessionIntent(attempt) + if (!wasStreaming && freshSendToken && activeFreshSendToken === freshSendToken) { + activeFreshSendToken = null + options.activeStreamTaskId.value = '' + options.activeStreamSessionKey.value = '' + options.stream.endStreaming() + } + // This response no longer owns the exact transcript (or predates the + // branch currently shown). Keep task bookkeeping authoritative, but + // quarantine visible rows and terminal echoes offscreen. + return 'accepted' + } if (!commitAcceptedVisibleReplay({ messageId: res?.userMessageId || res?.messageId || '', turnId: acceptedTaskId(res), @@ -3144,6 +4273,7 @@ export function useChatSend(options: UseChatSendOptions) { const terminalStatus = terminalResponseStatus(res) if (responseHandoff) { responseHandoff.acceptedTaskId = taskId + responseHandoff.acceptedTaskStatus = taskAcceptanceStatus(res) responseHandoff.terminalResponse = Boolean(terminalStatus) } const stoppedByUser = acceptanceTransaction.stoppedByUser @@ -3205,6 +4335,7 @@ export function useChatSend(options: UseChatSendOptions) { ) responseHandoff.stoppedByUser = true responseHandoff.acceptedTaskId = taskId + responseHandoff.acceptedTaskStatus = taskAcceptanceStatus(res) responseHandoff.terminalResponse = Boolean(terminalStatus) await handoffResponseSession(acceptedSessionKey, responseHandoff) } else if (responseHandoff && acceptedSessionKey === requestSessionKey) { @@ -3249,6 +4380,7 @@ export function useChatSend(options: UseChatSendOptions) { durableHandoffRecord, ) responseHandoff.acceptedTaskId = taskId + responseHandoff.acceptedTaskStatus = taskAcceptanceStatus(res) responseHandoff.terminalResponse = Boolean(terminalStatus) await handoffResponseSession(decision.responseSessionKey, responseHandoff) } else if (responseHandoff && decision.reason === 'same_session') { @@ -3265,6 +4397,7 @@ export function useChatSend(options: UseChatSendOptions) { terminalStatus && responseIsCurrent && options.sessionKey.value === terminalSessionKey + && attemptTranscriptIdentityStillOwned(attempt) ) { handleTerminalResponse(res, freshSendToken, { finishFreshStream: !wasStreaming, @@ -3277,19 +4410,80 @@ export function useChatSend(options: UseChatSendOptions) { } catch (err: unknown) { const rpcError = err as RpcClientError | null | undefined const acceptedError = acceptedErrorInfo(err) - if (!acceptedError) setAttemptPromptAnnotations(attempt, []) - if (acceptedError && !commitAcceptedVisibleReplay({ - messageId: acceptedError.messageId, - })) { + let acceptedResponseOwnsVisibleTranscript = !acceptedError + || ( + !backgroundReceiptReplay + && validateAttemptMessageEditTranscript(attempt) + ) + if ( + acceptedError + && acceptedResponseOwnsVisibleTranscript + && attempt.messageEditTranscriptOwner + && options.commitMessageEdit?.( + attempt.messageEditTranscriptOwner.generation, + ) === false + ) { + acceptedResponseOwnsVisibleTranscript = false + } + if (!acceptedError && attemptOwnsMessageEditTranscript(attempt)) { + setAttemptPromptAnnotations(attempt, []) + } + if ( + acceptedError + && acceptedResponseOwnsVisibleTranscript + && !commitAcceptedVisibleReplay({ + messageId: acceptedError.messageId, + }) + ) { options.scheduleHistorySync() } if ( acceptedError + && acceptedResponseOwnsVisibleTranscript && recoveredAttempt?.clientRequestId === attempt.clientRequestId ) { recoveredAttempt = null } if (acceptedError) consumeAcceptedSessionIntent(attempt) + if (acceptedError && !acceptedResponseOwnsVisibleTranscript) { + attempt.acceptanceResolved = true + attempt.acceptedSessionKey = acceptedError.sessionKey || requestSessionKey + if ( + options.sessionKey.value === requestSessionKey + && attempt.acceptedSessionKey === requestSessionKey + ) { + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + '', + acceptedError.terminalWithoutTask ? 'failed' : false, + ) + } + if (responseHandoff) { + responseHandoff.terminalResponse = acceptedError.terminalWithoutTask + const finalized = await finalizeBackgroundResponseHandoff( + responseHandoff, + attempt.acceptedSessionKey, + ) + if (!finalized) { + attempt.acceptanceResolved = false + recoveredAttempt = attempt + scheduleAcceptanceRecovery(attempt) + } + } + if ( + attempt.acceptanceResolved + && recoveredAttempt?.clientRequestId === attempt.clientRequestId + ) { + recoveredAttempt = null + } + if (!wasStreaming && freshSendToken && activeFreshSendToken === freshSendToken) { + activeFreshSendToken = null + options.activeStreamTaskId.value = '' + options.activeStreamSessionKey.value = '' + options.stream.endStreaming() + } + return 'accepted' + } const acceptedSessionKey = acceptedError?.sessionKey || requestSessionKey const rememberRetryableAttempt = (restoreComposer: boolean) => { if (!shouldRestoreSendAttempt(err)) return @@ -3299,13 +4493,17 @@ export function useChatSend(options: UseChatSendOptions) { if (sendOpts.rememberRetryableAttempt) { sendOpts.rememberRetryableAttempt(attempt) } else { + rememberRecoveryComposerSnapshot(attempt) recoveredAttempt = attempt + quarantineRecoveredAttemptIfUnrelated() } } else if (acceptanceUnknown) { // The optimistic user bubble already owns this payload. Keep its // immutable request identity for exact replay without presenting the // same text as a new editable draft. + rememberRecoveryComposerSnapshot(attempt) recoveredAttempt = attempt + quarantineRecoveredAttemptIfUnrelated() } else if (restoreComposer) { restoreSendAttempt(attempt, { requiresIdempotentReplay: false, @@ -3377,9 +4575,51 @@ export function useChatSend(options: UseChatSendOptions) { if (responseHandoff && acceptedSessionKey === requestSessionKey) { await handoffResponseSession(requestSessionKey, responseHandoff) } + if (!attemptTranscriptIdentityStillOwned(attempt)) { + attempt.acceptanceResolved = true + if (acceptedSessionKey === requestSessionKey) { + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + '', + acceptedError.terminalWithoutTask ? 'failed' : false, + ) + } + if (!wasStreaming && freshSendToken && activeFreshSendToken === freshSendToken) { + activeFreshSendToken = null + options.activeStreamTaskId.value = '' + options.activeStreamSessionKey.value = '' + options.stream.endStreaming() + } + return 'accepted' + } bindUserMessageId(attempt.clientMessageId, acceptedError.messageId) options.scheduleHistorySync() } + if ( + responseHandoff + && rpcError?.accepted === false + && backgroundReceiptReplay + ) { + backgroundRejectionRetired = await retireRejectedBackgroundResponseHandoff( + responseHandoff, + err, + ) + if (backgroundRejectionRetired) { + attempt.backgroundRejectionPending = false + attempt.backgroundRejectionError = undefined + if (recoveredAttempt?.clientRequestId === attempt.clientRequestId) { + recoveredAttempt = null + } + sendOpts.onBackgroundRejectionRetired?.() + } else { + attempt.acceptanceResolved = false + attempt.backgroundRejectionPending = true + attempt.backgroundRejectionError = err + recoveredAttempt = attempt + const retirement = scheduleAcceptanceRecovery(attempt) + if (retirement) sendOpts.onBackgroundRejectionRetirement?.(retirement) + } + } if (options.sessionKey.value !== requestSessionKey) { rememberRetryableAttempt(false) recordSessionNavigationDiag('send.error.stale', { @@ -3402,24 +4642,51 @@ export function useChatSend(options: UseChatSendOptions) { options.stream.endStreaming() } if (responseHandoff && rpcError?.accepted === false) { - if (sendOpts.requirePreparedHandoff && rpcError.retryable !== false) { + if ( + !backgroundReceiptReplay + && sendOpts.requirePreparedHandoff + && rpcError.retryable !== false + ) { await resetResponseHandoffForRetry(responseHandoff, attempt) - } else if (rpcError.retryable === false) { + } else if (!backgroundReceiptReplay && rpcError.retryable === false) { await markResponseHandoffFailed(responseHandoff, err) } } - rememberRetryableAttempt(true) - if (acceptedError || !sendOpts.suppressRejectedFailureMessage) { - options.messages.value.push({ + if ( + !acceptedError + && + attempt.messageEditTranscriptOwner + && !validateAttemptMessageEditTranscript(attempt) + ) { + // History hydration or another owner replaced this edit while the RPC + // was pending. Request cleanup above is still required, but the stale + // rejection must not restore text/fork state or append into the new + // owner's transcript. + return acceptedError ? 'accepted' : 'retryable_failure' + } + if (!backgroundRejectionRetired) rememberRetryableAttempt(true) + if ( + acceptedError + || (!backgroundReceiptReplay && !sendOpts.suppressRejectedFailureMessage) + ) { + const errorRow: ChatMessage = { role: 'error', text: sendFailureMessage(err, paramsHaveArtifactContext(attempt.params)), errorCode: errorCode(err), ts: new Date().toISOString(), - }) + } + options.messages.value.push(errorRow) + extendAttemptMessageEditTranscript(attempt, [errorRow]) + if (rpcError?.accepted === false) { + adoptDefinitelyRejectedEditRows(errorRow) + } } return acceptedError ? 'accepted' : 'retryable_failure' } finally { attempt.acceptanceInFlight = false + if (backgroundReceiptReplay) { + options.finishBackgroundReceiptReplay?.(attempt.clientMessageId) + } finishAcceptanceTransaction(acceptanceTransaction) finishResponseHandoff(responseHandoff) } @@ -3557,7 +4824,9 @@ export function useChatSend(options: UseChatSendOptions) { options.pendingWorkspaceId.value = attempt.workspaceId } attempt.requiresIdempotentReplay = recovery.requiresIdempotentReplay + rememberRecoveryComposerSnapshot(attempt) recoveredAttempt = attempt + quarantineRecoveredAttemptIfUnrelated() options.autoResizeTextarea() } diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index 9143463021..45bfd7b5a6 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -362,4 +362,80 @@ describe('BrowserPendingInputWal atomic handoff cancellation', () => { wal!.close() }) + + it('releases a failed owned handoff only back to its source session', async () => { + const factory = new ControlledIdbFactory() + const wal = createPendingInputWal(factory.idbFactory) + expect(wal).not.toBeNull() + + const ownerRequestId = 'owner-failed-release' + const sourceSessionKey = 'agent:main:webchat:source' + const pendingInputId = 'pending-failed-release' + const pending: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId, + sessionKey: sourceSessionKey, + clientRequestId: 'pending-client-request', + clientMessageId: 'pending-client-message', + text: 'return to the source queue', + attachments: [], + intent: null, + ownerRequestId, + state: 'saving', + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const handoff: ResponseHandoffWalRecord = { + schemaVersion: 1, + ownerRequestId, + requestSessionKey: sourceSessionKey, + clientRequestId: ownerRequestId, + clientMessageId: 'failed-receipt-message', + params: { + sessionKey: sourceSessionKey, + message: 'rejected fork', + clientRequestId: ownerRequestId, + clientMessageId: 'failed-receipt-message', + }, + composerText: 'rejected fork', + recoveryAttachments: [], + backgroundOnly: true, + walOwnerId: 'failed-wal-owner', + walRevision: 3, + state: 'failed', + errorCode: 'rejected', + createdAt: 1, + updatedAt: 2, + } + + await wal!.put(pending) + await wal!.putHandoff!(handoff) + + await expect(wal!.acceptHandoff!( + ownerRequestId, + 'agent:main:webchat:other', + )).rejects.toThrow('Response handoff is not durably accepted') + + const released = await wal!.acceptHandoff!(ownerRequestId, sourceSessionKey) + expect(released?.handoff).toMatchObject({ + state: 'accepted', + acceptedSessionKey: sourceSessionKey, + }) + expect(released?.records).toEqual([ + expect.objectContaining({ + pendingInputId, + sessionKey: sourceSessionKey, + ownerRequestId: undefined, + state: 'saving', + walRevision: 2, + }), + ]) + expect(factory.record(PENDING_STORE, pendingInputId)).toMatchObject({ + sessionKey: sourceSessionKey, + ownerRequestId: undefined, + }) + + wal!.close() + }) }) diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index 0e565aa98d..ce7d1bbdee 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -52,6 +52,8 @@ export interface ResponseHandoffWalRecord { recoveryAttachments: Attachment[] /** A protocol-owned replay must never be restored into the user composer. */ restoreComposerOnFailure?: boolean + /** Accepted offscreen; recovery may retire it but must never adopt its target. */ + backgroundOnly?: boolean /** Stable source-session + barrier identity used for cross-tab coordination. */ replayCoordinationKey?: string /** Identifies the live dispatcher allowed to arm an unsubmitted handoff. */ @@ -60,6 +62,10 @@ export interface ResponseHandoffWalRecord { walRevision?: number state: ResponseHandoffWalState acceptedSessionKey?: string + /** Accepted task identity retained across a crash before owner retirement. */ + acceptedTaskId?: string + /** Gateway lifecycle status paired with the accepted task identity. */ + acceptedTaskStatus?: string errorCode?: string createdAt: number updatedAt: number @@ -203,6 +209,10 @@ function isResponseHandoffWalRecord(value: unknown): value is ResponseHandoffWal record.restoreComposerOnFailure === undefined || typeof record.restoreComposerOnFailure === 'boolean' ) + && ( + record.backgroundOnly === undefined + || typeof record.backgroundOnly === 'boolean' + ) && ( record.replayCoordinationKey === undefined || ( @@ -219,6 +229,14 @@ function isResponseHandoffWalRecord(value: unknown): value is ResponseHandoffWal || (Number.isSafeInteger(record.walRevision) && record.walRevision >= 1) ) && ['preparing', 'submitting', 'accepted', 'failed'].includes(String(record.state || '')) + && ( + record.acceptedTaskId === undefined + || (typeof record.acceptedTaskId === 'string' && record.acceptedTaskId.length > 0) + ) + && ( + record.acceptedTaskStatus === undefined + || (typeof record.acceptedTaskStatus === 'string' && record.acceptedTaskStatus.length > 0) + ) && ( record.state !== 'preparing' || ( @@ -497,7 +515,15 @@ class BrowserPendingInputWal implements PendingInputWal { transaction.abort() throw new Error('Response handoff no longer exists') } - if (rawHandoff.walOwnerId && rawHandoff.state !== 'accepted') { + const releasesFailedOwnerToSource = ( + rawHandoff.state === 'failed' + && acceptedSessionKey === rawHandoff.requestSessionKey + ) + if ( + rawHandoff.walOwnerId + && rawHandoff.state !== 'accepted' + && !releasesFailedOwnerToSource + ) { transaction.abort() throw new Error('Response handoff is not durably accepted') } diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 7db5e3966f..74e25f26d1 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1697,6 +1697,18 @@ const isStopPending = computed(() => ( || acceptanceRecoveryPending.value )) let bindActiveStreamTask = (taskId: string) => { activeStreamTaskId.value = taskId } +let beginBackgroundReceiptReplay = (_clientMessageId: string, _holdHistory = false) => {} +let trackBackgroundReceiptTask = ( + _clientMessageId: string, + _taskId: string, + _terminal: boolean | string = false, + _allowProjection = true, + _retireParentProjection = false, + _acceptedStatus = '', +) => {} +let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} +let holdBackgroundReceiptReconciliation = () => {} +let releaseBackgroundReceiptReconciliation = () => {} let restoreLiveTurnSnapshot = (_snapshot: SessionReadSnapshot) => {} function projectWorkspaceFromSessionRead( @@ -2387,6 +2399,8 @@ const { loadEarlierHistory, retryHistory: retryHistoryRequest, scheduleHistorySync, + holdHistorySync, + releaseHistorySync, cancelAnchorStabilization, cancelActiveHistory, markSessionMissing, @@ -2541,6 +2555,7 @@ const voiceCapability = useSetupStatus<{ audioConfigured?: boolean }>(injectedSe const voiceReady = computed(() => voiceCapability.data.value?.audioConfigured === true) const chatMessageActions = useChatMessageActions({ + sessionKey, messages, inputText, isStreaming, @@ -2563,11 +2578,25 @@ const chatMessageActions = useChatMessageActions({ }, notifyMessagePending: () => pushToast(t('chat.toast.messageStillSaving'), { tone: 'info' }), notifyEditBlocked: () => pushToast(t('chat.pending.editWhileStreaming'), { tone: 'info' }), + onEditStarted: () => { + holdBackgroundReceiptReconciliation() + holdHistorySync() + }, + onEditSettled: () => { + releaseBackgroundReceiptReconciliation() + releaseHistorySync() + }, }) const { copyMessage, regenerateMessage, editMessage, + cancelEdit, + commitEdit, + validateEditOwner, + adoptRejectedEditRows, + editGeneration, + editActive, } = chatMessageActions async function handleRegenerateMessage( @@ -3311,6 +3340,7 @@ const chatComposerShortcuts = useChatComposerShortcuts({ popPendingTail, enqueuePendingInput, sendCurrentInput: () => sendCurrentInput(), + cancelMessageEdit: () => cancelEdit(), }) const { onTextareaBeforeInput, @@ -3338,6 +3368,11 @@ const chatSend = useChatSend({ runMode, pendingAttachments, composerRevision, + messageEditGeneration: editGeneration, + messageEditActive: editActive, + validateMessageEditOwner: validateEditOwner, + commitMessageEdit: commitEdit, + adoptRejectedMessageEditRows: adoptRejectedEditRows, pendingSessionIntent, pendingWorkspaceId, sendBlockedReason: effectiveSendBlockedReason, @@ -3437,6 +3472,29 @@ const chatSend = useChatSend({ restoreSteerIntoComposer: text => appendComposerText(text), popAllPendingIntoComposer, reconcileTaskOwnership: () => retrySessionMetadata(), + beginBackgroundReceiptReplay: (clientMessageId, holdHistory) => ( + beginBackgroundReceiptReplay(clientMessageId, holdHistory) + ), + trackBackgroundReceiptTask: ( + clientMessageId, + taskId, + terminal, + allowProjection, + retireParentProjection, + acceptedStatus, + ) => ( + trackBackgroundReceiptTask( + clientMessageId, + taskId, + terminal, + allowProjection, + retireParentProjection, + acceptedStatus, + ) + ), + finishBackgroundReceiptReplay: clientMessageId => ( + finishBackgroundReceiptReplay(clientMessageId) + ), classifySlashCommand, executeSlashCommand, closeSlashMenu, @@ -3897,6 +3955,11 @@ const rpcEventHandlers = useChatRpcEventHandlers({ refreshRunModePreference: refreshPostBootstrapMetadata, }) bindActiveStreamTask = rpcEventHandlers.bindActiveStreamTask +beginBackgroundReceiptReplay = rpcEventHandlers.beginBackgroundReceiptReplay +trackBackgroundReceiptTask = rpcEventHandlers.trackBackgroundReceiptTask +finishBackgroundReceiptReplay = rpcEventHandlers.finishBackgroundReceiptReplay +holdBackgroundReceiptReconciliation = rpcEventHandlers.holdBackgroundReceiptReconciliation +releaseBackgroundReceiptReconciliation = rpcEventHandlers.releaseBackgroundReceiptReconciliation restoreLiveTurnSnapshot = rpcEventHandlers.restoreLiveTurnSnapshot const { streamThinkingText,