From 26f525ae16d15ba711f8576a6e94ca209fe7ffa7 Mon Sep 17 00:00:00 2001 From: RickyYii <237135932+RickyYii@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:28:07 +0000 Subject: [PATCH 01/24] fix(webui): let Escape undo a message edit instead of stranding the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking edit on a user message truncates the in-memory transcript at that message before the user has confirmed anything: `editMessage` slices `messages` and drops the edited text into the composer. On a two-message conversation that leaves the empty state — "暂无消息。" — on screen. Escape then cleared the composer and did nothing else, so the only exit from edit mode destroyed the draft and left the empty transcript in place. Reloading brings the history back, because the server still has it, but nothing on screen says so: what the user sees is a conversation that vanished (#1372). `editMessage` now records what it is about to overwrite — the transcript array and the composer's previous contents — and `cancelEdit()` puts both back. Escape offers that before it clears the draft, and offers it even when the composer is already empty, which the old guard refused: emptying the box by hand used to remove the last way out of the truncated state. The restore point is honoured only while `pendingForkBeforeMessageId` still holds the id the edit set. Sending consumes that id and a second edit replaces it; in both cases the truncation has been made real by something the user did mean, and putting the old array back would resurrect messages the fork has already replaced. Cancelling is also one-shot, so a later Escape cannot reach a stale transcript. `regenerateMessage` truncates the same way but sends on the next tick, so it is never left sitting in the truncated state and needs no restore point. Closes #1372. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LgCn6RgA7fr4Fi1zTWcJt7 (cherry picked from commit cfca176fe75ab3ed1e921b566dec618901285f3f) --- .../chat/useChatComposerShortcuts.test.ts | 61 ++++++++++ .../chat/useChatComposerShortcuts.ts | 29 ++++- .../chat/useChatMessageActions.test.ts | 112 ++++++++++++++++++ .../composables/chat/useChatMessageActions.ts | 48 ++++++++ opensquilla-webui/src/views/ChatView.vue | 2 + 5 files changed, 246 insertions(+), 6 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts index de03139c8a..f5e2222cb4 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,62 @@ 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('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..2e6b04823b 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,22 @@ 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' && !options.isStreaming.value && options.pendingQueue.value.length === 0) { + // 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.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/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index 8e2b3c9fd9..e7e9c51a70 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -113,6 +113,118 @@ 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('keeps the newer edit when a second one replaces the first', () => { + 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', + })) + 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 second edit's restore point wins: back to what the first edit left, + // not to the untouched transcript. Cancelling one edit must not undo the + // other. + expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A']) + }) + 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..eeb15b0fad 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -49,7 +49,18 @@ export interface UseChatMessageActionsOptions { notifyEditBlocked?: () => void } +interface EditRestorePoint { + /** The transcript as it stood before edit truncated it. */ + messages: ChatMessage[] + /** Whatever the composer held before edit overwrote it with the message. */ + inputText: string + /** Ties the restore point to the edit that made it; see `cancelEdit`. */ + forkBeforeMessageId: string +} + export function useChatMessageActions(options: UseChatMessageActionsOptions) { + let editRestorePoint: EditRestorePoint | null = 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 @@ -205,6 +216,16 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { return } const text = sourceMessage.text || '' + // 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 = { + messages: options.messages.value, + inputText: options.inputText.value, + forkBeforeMessageId, + } options.pendingForkBeforeMessageId.value = forkBeforeMessageId options.messages.value = options.messages.value.slice(0, msgIndex) options.inputText.value = text @@ -212,9 +233,36 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { options.focusComposer() } + /** + * Put the transcript and the draft back, if an edit is still uncommitted. + * + * Returns whether anything was restored, so a caller can tell an edit + * cancellation apart from an ordinary Escape and act on only one of them. + * + * The restore point is only honoured while `pendingForkBeforeMessageId` still + * holds the id the edit set. Sending consumes that id, and a second edit + * replaces it; in both cases the truncation has been made real by something + * the user did mean, and resurrecting the old array would put back messages + * the server no longer has. + */ + function cancelEdit(): boolean { + const restore = editRestorePoint + if (!restore) return false + editRestorePoint = null + if (options.pendingForkBeforeMessageId.value !== restore.forkBeforeMessageId) { + return false + } + options.pendingForkBeforeMessageId.value = null + options.messages.value = restore.messages + options.inputText.value = restore.inputText + options.autoResizeTextarea() + return true + } + return { copyMessage, regenerateMessage, editMessage, + cancelEdit, } } diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 7db5e3966f..eba654c6f9 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -2568,6 +2568,7 @@ const { copyMessage, regenerateMessage, editMessage, + cancelEdit, } = chatMessageActions async function handleRegenerateMessage( @@ -3311,6 +3312,7 @@ const chatComposerShortcuts = useChatComposerShortcuts({ popPendingTail, enqueuePendingInput, sendCurrentInput: () => sendCurrentInput(), + cancelMessageEdit: () => cancelEdit(), }) const { onTextareaBeforeInput, From 47f0dd550bbe60ebf500850d666d79f4096de603 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Tue, 1 Sep 2026 23:16:10 +0800 Subject: [PATCH 02/24] Fence message edit cancellation to its session --- .../chat/useChatComposerShortcuts.test.ts | 14 +++++ .../chat/useChatComposerShortcuts.ts | 8 ++- .../chat/useChatMessageActions.test.ts | 58 ++++++++++++++++++- .../composables/chat/useChatMessageActions.ts | 25 +++++++- .../chat/useChatSend.attachments.test.ts | 1 + opensquilla-webui/src/views/ChatView.vue | 1 + 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts index f5e2222cb4..60f1a45f63 100644 --- a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.test.ts @@ -326,6 +326,20 @@ describe('Escape and message edits', () => { 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' }) diff --git a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts index 2e6b04823b..a9ff6d698b 100644 --- a/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts +++ b/opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts @@ -123,7 +123,7 @@ export function useChatComposerShortcuts(options: UseChatComposerShortcutsOption } } - if (e.key === 'Escape' && !options.isStreaming.value && options.pendingQueue.value.length === 0) { + 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. @@ -132,7 +132,11 @@ export function useChatComposerShortcuts(options: UseChatComposerShortcutsOption clearTextareaUndoState() return } - if (options.inputText.value) { + if ( + !options.isStreaming.value + && options.pendingQueue.value.length === 0 + && options.inputText.value + ) { e.preventDefault() clearTextareaUndoState() options.inputText.value = '' diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index e7e9c51a70..6d347a4a0e 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -66,8 +66,10 @@ function makeOptions( ) => string = text => text, aiGeneratedLabel?: () => string, ) { + const sessionKey = ref('agent:main:webchat:A') const pendingForkBeforeMessageId = ref(null) const options: UseChatMessageActionsOptions = { + sessionKey, messages: ref(messages), inputText: ref(''), isStreaming: ref(false), @@ -83,7 +85,7 @@ function makeOptions( canDeliver: () => true, notifyDeliveryBlocked: vi.fn(), } - return { api: useChatMessageActions(options), options, pendingForkBeforeMessageId } + return { api: useChatMessageActions(options), options, sessionKey, pendingForkBeforeMessageId } } beforeEach(() => { @@ -202,6 +204,60 @@ describe('useChatMessageActions branching edits', () => { 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('does not restore after another transcript owner replaces the edit state', () => { + const { api, options } = 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(false) + expect(options.messages.value.map(message => message.text)).toEqual(['new owner']) + expect(options.inputText.value).toBe('new owner draft') + }) + it('keeps the newer edit when a second one replaces the first', () => { 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 eeb15b0fad..b42a66fcbe 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, 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 @@ -50,6 +51,10 @@ export interface UseChatMessageActionsOptions { } interface EditRestorePoint { + /** Session owner; restore points never cross a session boundary. */ + sessionKey: string + /** The exact transcript array installed by this edit. */ + editingMessages: ChatMessage[] /** The transcript as it stood before edit truncated it. */ messages: ChatMessage[] /** Whatever the composer held before edit overwrote it with the message. */ @@ -61,6 +66,13 @@ interface EditRestorePoint { export function useChatMessageActions(options: UseChatMessageActionsOptions) { let editRestorePoint: EditRestorePoint | null = null + // 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, () => { + editRestorePoint = null + }, { flush: 'sync' }) + 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 @@ -216,18 +228,21 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { return } const text = sourceMessage.text || '' + const editingMessages = options.messages.value.slice(0, msgIndex) // 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, messages: options.messages.value, inputText: options.inputText.value, forkBeforeMessageId, } 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() @@ -249,7 +264,11 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { const restore = editRestorePoint if (!restore) return false editRestorePoint = null - if (options.pendingForkBeforeMessageId.value !== restore.forkBeforeMessageId) { + if ( + options.sessionKey.value !== restore.sessionKey + || toRaw(options.messages.value) !== restore.editingMessages + || options.pendingForkBeforeMessageId.value !== restore.forkBeforeMessageId + ) { return false } options.pendingForkBeforeMessageId.value = null diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index bd671de052..0ba2f9befd 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3233,6 +3233,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, diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index eba654c6f9..683f27940f 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -2541,6 +2541,7 @@ const voiceCapability = useSetupStatus<{ audioConfigured?: boolean }>(injectedSe const voiceReady = computed(() => voiceCapability.data.value?.audioConfigured === true) const chatMessageActions = useChatMessageActions({ + sessionKey, messages, inputText, isStreaming, From 927319c25978d2e930af85e5de955bef0881b53a Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Tue, 1 Sep 2026 23:25:41 +0800 Subject: [PATCH 03/24] Preserve message edit ownership on cancellation --- .../chat/useChatMessageActions.test.ts | 30 +++++++++++++++++++ .../composables/chat/useChatMessageActions.ts | 15 ++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index 6d347a4a0e..9f08f087f2 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -258,6 +258,34 @@ describe('useChatMessageActions branching edits', () => { expect(options.inputText.value).toBe('new owner draft') }) + it('does not restore after the edit transcript is replaced in place', () => { + 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', + })) + 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(false) + 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('keeps the newer edit when a second one replaces the first', () => { const { api, options, pendingForkBeforeMessageId } = makeOptions([ { role: 'user', text: 'A', ts: null, messageId: 'msg-A' }, @@ -279,6 +307,8 @@ describe('useChatMessageActions branching edits', () => { // not to the untouched transcript. Cancelling one edit must not undo the // other. expect(options.messages.value.map(message => message.text)).toEqual(['A', 'ack A']) + expect(options.inputText.value).toBe('B') + expect(pendingForkBeforeMessageId.value).toBe('msg-B') }) it('records the previous user message id before regenerating', async () => { diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index b42a66fcbe..864caa7f34 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -55,10 +55,14 @@ interface EditRestorePoint { 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 } @@ -237,8 +241,10 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { 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, } options.pendingForkBeforeMessageId.value = forkBeforeMessageId @@ -264,14 +270,19 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { const restore = editRestorePoint if (!restore) return false editRestorePoint = null + const currentMessages = options.messages.value if ( options.sessionKey.value !== restore.sessionKey - || toRaw(options.messages.value) !== restore.editingMessages + || toRaw(currentMessages) !== restore.editingMessages + || currentMessages.length !== restore.editingMessageOwners.length + || currentMessages.some( + (message, index) => toRaw(message) !== restore.editingMessageOwners[index], + ) || options.pendingForkBeforeMessageId.value !== restore.forkBeforeMessageId ) { return false } - options.pendingForkBeforeMessageId.value = null + options.pendingForkBeforeMessageId.value = restore.previousForkBeforeMessageId options.messages.value = restore.messages options.inputText.value = restore.inputText options.autoResizeTextarea() From b45391d3b0510f86c972ab0ee1e451bf8195d33d Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Tue, 1 Sep 2026 23:53:47 +0800 Subject: [PATCH 04/24] Cancel stale sends after message edit rollback --- .../composables/chat/useChatMessageActions.ts | 7 ++- .../chat/useChatSend.attachments.test.ts | 61 +++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 17 ++++++ opensquilla-webui/src/views/ChatView.vue | 2 + 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index 864caa7f34..2acf833580 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -1,4 +1,4 @@ -import { nextTick, toRaw, watch, type Ref } from 'vue' +import { nextTick, ref, toRaw, watch, type Ref } from 'vue' import type { ChatMessage, ChatRenderedMessage, @@ -69,12 +69,14 @@ interface EditRestorePoint { export function useChatMessageActions(options: UseChatMessageActionsOptions) { let editRestorePoint: EditRestorePoint | null = null + const editGeneration = ref(0) // 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, () => { editRestorePoint = null + editGeneration.value += 1 }, { flush: 'sync' }) function copyableMessageText(message: ChatRenderedMessage): string { @@ -233,6 +235,7 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { } const text = sourceMessage.text || '' const editingMessages = options.messages.value.slice(0, msgIndex) + 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: @@ -282,6 +285,7 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { ) { return false } + editGeneration.value += 1 options.pendingForkBeforeMessageId.value = restore.previousForkBeforeMessageId options.messages.value = restore.messages options.inputText.value = restore.inputText @@ -294,5 +298,6 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { regenerateMessage, editMessage, cancelEdit, + editGeneration, } } diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 0ba2f9befd..2e730396ac 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2209,6 +2209,67 @@ describe('useChatSend attachment payloads', () => { expect(options.messages.value).toEqual([]) }) + it('abandons an edited send when Escape cancels it during project validation', async () => { + const sessionKey = ref('agent:main:webchat:test') + const messages = ref([ + { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, + { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, + ]) + 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', + }) + + let finishPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn(() => new Promise( + resolve => { + finishPreflight = () => resolve(null) + }, + )) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + 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('sends the clicked snapshot without clearing edits made during project validation', async () => { const originalAttachment: Attachment = { kind: 'staged', diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 326a499264..44d4fadefc 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -224,6 +224,7 @@ interface ComposerSnapshot { initialCollaborationMode: CollaborationMode | null initialRoutingMode: GatewayModelRoutingMode | null queueOwnerRequestId: string | null + messageEditGeneration: number | null } interface DispatchSendOptions { @@ -495,6 +496,8 @@ export interface UseChatSendOptions { runMode: Ref pendingAttachments: Ref composerRevision?: Readonly> + /** Invalidates a composer send when its message-edit owner is cancelled or replaced. */ + messageEditGeneration?: Readonly> pendingSessionIntent: Ref initialCollaborationMode: Readonly> initialRoutingMode: Readonly> @@ -791,9 +794,15 @@ export function useChatSend(options: UseChatSendOptions) { queueOwnerRequestId: queueOwnerContext?.sessionKey === options.sessionKey.value ? queueOwnerContext.ownerRequestId : null, + messageEditGeneration: options.messageEditGeneration?.value ?? null, } } + function messageEditOwnerMatchesSnapshot(snapshot: ComposerSnapshot): boolean { + return snapshot.messageEditGeneration === null + || options.messageEditGeneration?.value === snapshot.messageEditGeneration + } + function queueOwnerMatchesSnapshot(snapshot: ComposerSnapshot): boolean { const context = options.pendingQueueOwnerContext.value const currentOwnerRequestId = context?.sessionKey === options.sessionKey.value @@ -2287,6 +2296,7 @@ export function useChatSend(options: UseChatSendOptions) { if (await refreshedActiveProjectBlocksSend()) return } if (options.sessionKey.value !== requestSessionKey) return + if (!messageEditOwnerMatchesSnapshot(composerSnapshot)) return if (replayBlockedReason?.value) return await dispatchSend(exactReplayAttempt.text, { composerText, @@ -2294,6 +2304,7 @@ export function useChatSend(options: UseChatSendOptions) { queueMode: exactReplayAttempt.queueMode, retryAttempt: exactReplayAttempt, idempotentReplay: true, + preDispatchGuard: () => messageEditOwnerMatchesSnapshot(composerSnapshot), }) return } @@ -2308,6 +2319,7 @@ export function useChatSend(options: UseChatSendOptions) { if (await refreshedActiveProjectBlocksSend()) return } if (options.sessionKey.value !== requestSessionKey) return + if (!messageEditOwnerMatchesSnapshot(composerSnapshot)) return if (!queueOwnerMatchesSnapshot(composerSnapshot)) return if (options.sendBlockedReason?.value) return if ( @@ -2350,6 +2362,7 @@ export function useChatSend(options: UseChatSendOptions) { payload: payloadFromSnapshot(composerSnapshot), composerSnapshot, cancelIfComposerChanged: invocation.cancelIfComposerChanged, + preDispatchGuard: () => messageEditOwnerMatchesSnapshot(composerSnapshot), }) return } @@ -2362,6 +2375,7 @@ export function useChatSend(options: UseChatSendOptions) { if (slashClassification !== null) { if ( options.sessionKey.value !== requestSessionKey + || !messageEditOwnerMatchesSnapshot(composerSnapshot) || !composerMatchesSnapshot(composerSnapshot) || !queueOwnerMatchesSnapshot(composerSnapshot) || Boolean(options.sendBlockedReason?.value) @@ -2373,6 +2387,7 @@ export function useChatSend(options: UseChatSendOptions) { ) return if ( options.sessionKey.value !== requestSessionKey + || !messageEditOwnerMatchesSnapshot(composerSnapshot) || !composerMatchesSnapshot(composerSnapshot) || !queueOwnerMatchesSnapshot(composerSnapshot) || Boolean(options.sendBlockedReason?.value) @@ -2488,6 +2503,7 @@ export function useChatSend(options: UseChatSendOptions) { payload: payloadFromSnapshot(composerSnapshot), composerSnapshot, cancelIfComposerChanged: invocation.cancelIfComposerChanged, + preDispatchGuard: () => messageEditOwnerMatchesSnapshot(composerSnapshot), }) } @@ -2788,6 +2804,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()) diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 683f27940f..f4f8bbb0e7 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -2570,6 +2570,7 @@ const { regenerateMessage, editMessage, cancelEdit, + editGeneration, } = chatMessageActions async function handleRegenerateMessage( @@ -3341,6 +3342,7 @@ const chatSend = useChatSend({ runMode, pendingAttachments, composerRevision, + messageEditGeneration: editGeneration, pendingSessionIntent, pendingWorkspaceId, sendBlockedReason: effectiveSendBlockedReason, From e15a974d94a99e591646e23d66a4628f854da2c2 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 00:11:24 +0800 Subject: [PATCH 05/24] Fence stale edit ownership from queued sends --- .../chat/useChatMessageActions.test.ts | 12 +- .../composables/chat/useChatMessageActions.ts | 20 ++- .../chat/useChatSend.attachments.test.ts | 126 ++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 6 + 4 files changed, 155 insertions(+), 9 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index 9f08f087f2..eedaff782f 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -234,8 +234,8 @@ describe('useChatMessageActions branching edits', () => { expect(options.inputText.value).toBe('session B draft') }) - it('does not restore after another transcript owner replaces the edit state', () => { - const { api, options } = makeOptions([ + 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' }, ]) @@ -254,12 +254,14 @@ describe('useChatMessageActions branching edits', () => { options.inputText.value = 'new owner draft' expect(api.cancelEdit()).toBe(false) + 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('does not restore after the edit transcript is replaced in place', () => { - const { api, options } = makeOptions([ + 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' }, @@ -279,6 +281,8 @@ describe('useChatMessageActions branching edits', () => { options.inputText.value = 'new owner draft' expect(api.cancelEdit()).toBe(false) + 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', diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index 2acf833580..08ea994a30 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -276,16 +276,26 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { const currentMessages = options.messages.value if ( options.sessionKey.value !== restore.sessionKey - || toRaw(currentMessages) !== restore.editingMessages - || currentMessages.length !== restore.editingMessageOwners.length - || currentMessages.some( - (message, index) => toRaw(message) !== restore.editingMessageOwners[index], - ) || options.pendingForkBeforeMessageId.value !== restore.forkBeforeMessageId ) { return false } editGeneration.value += 1 + const stillOwnsTranscript = ( + toRaw(currentMessages) === restore.editingMessages + && currentMessages.length === restore.editingMessageOwners.length + && currentMessages.every( + (message, index) => toRaw(message) === restore.editingMessageOwners[index], + ) + ) + if (!stillOwnsTranscript) { + // 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. + options.pendingForkBeforeMessageId.value = null + return false + } options.pendingForkBeforeMessageId.value = restore.previousForkBeforeMessageId options.messages.value = restore.messages options.inputText.value = restore.inputText diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 2e730396ac..8b3463391b 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2270,6 +2270,132 @@ describe('useChatSend attachment payloads', () => { expect(pendingForkBeforeMessageId.value).toBeNull() }) + it('abandons an edited send when its transcript owner changes during validation', async () => { + const sessionKey = ref('agent:main:webchat:test') + const messages = ref([ + { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, + { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, + ]) + 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', + }) + + let finishPreflight!: () => void + const validateActiveProjectBeforeSend = vi.fn(() => new Promise( + resolve => { + finishPreflight = () => resolve(null) + }, + )) + const { api, rpc } = makeOptions({ + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + 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(false) + 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('does not start async queue persistence for a fork edit while work is active', async () => { + const sessionKey = ref('agent:main:webchat:test') + const messages = ref([ + { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, + { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, + ]) + 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', + }) + + 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, + 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('sends the clicked snapshot without clearing edits made during project validation', async () => { const originalAttachment: Attachment = { kind: 'staged', diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 44d4fadefc..cb0fdbf37a 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -2442,6 +2442,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) From 6d63e61715f9fd7138a395bebcc703ffc9932708 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 17:18:57 +0800 Subject: [PATCH 06/24] Harden message edit ownership across retries --- .../chat/useChatMessageActions.test.ts | 152 +++- .../composables/chat/useChatMessageActions.ts | 152 +++- .../chat/useChatSend.attachments.test.ts | 725 ++++++++++++++++-- .../src/composables/chat/useChatSend.ts | 256 ++++++- opensquilla-webui/src/views/ChatView.vue | 4 + 5 files changed, 1149 insertions(+), 140 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index eedaff782f..6989fac4a2 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -253,7 +253,7 @@ describe('useChatMessageActions branching edits', () => { ] options.inputText.value = 'new owner draft' - expect(api.cancelEdit()).toBe(false) + 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']) @@ -280,7 +280,7 @@ describe('useChatMessageActions branching edits', () => { }) options.inputText.value = 'new owner draft' - expect(api.cancelEdit()).toBe(false) + expect(api.cancelEdit()).toBe(true) expect(api.editGeneration.value).toBe(2) expect(pendingForkBeforeMessageId.value).toBeNull() expect(options.messages.value).toBe(currentOwner) @@ -290,29 +290,167 @@ describe('useChatMessageActions branching edits', () => { expect(options.inputText.value).toBe('new owner draft') }) - it('keeps the newer edit when a second one replaces the first', () => { + 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 second edit's restore point wins: back to what the first edit left, - // not to the untouched transcript. Cancelling one edit must not undo the - // other. + // 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('B') + 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('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 () => { diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index 08ea994a30..0b7b76d31f 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -65,6 +65,8 @@ interface EditRestorePoint { 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) { @@ -79,6 +81,31 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { editGeneration.value += 1 }, { 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 + 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 @@ -170,6 +197,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( @@ -235,6 +269,26 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { } 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 + } 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 @@ -249,6 +303,7 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { inputText: options.inputText.value, previousForkBeforeMessageId: options.pendingForkBeforeMessageId.value, forkBeforeMessageId, + previousRestorePoint: editRestorePoint, } options.pendingForkBeforeMessageId.value = forkBeforeMessageId options.messages.value = editingMessages @@ -260,42 +315,35 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { /** * Put the transcript and the draft back, if an edit is still uncommitted. * - * Returns whether anything was restored, so a caller can tell an edit - * cancellation apart from an ordinary Escape and act on only one of them. + * 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 restore point is only honoured while `pendingForkBeforeMessageId` still - * holds the id the edit set. Sending consumes that id, and a second edit - * replaces it; in both cases the truncation has been made real by something - * the user did mean, and resurrecting the old array would put back messages - * the server no longer has. + * 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 - editRestorePoint = null - const currentMessages = options.messages.value - if ( - options.sessionKey.value !== restore.sessionKey - || options.pendingForkBeforeMessageId.value !== restore.forkBeforeMessageId - ) { + 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 + editGeneration.value += 1 return false } - editGeneration.value += 1 - const stillOwnsTranscript = ( - toRaw(currentMessages) === restore.editingMessages - && currentMessages.length === restore.editingMessageOwners.length - && currentMessages.every( - (message, index) => toRaw(message) === restore.editingMessageOwners[index], - ) - ) - if (!stillOwnsTranscript) { + 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. - options.pendingForkBeforeMessageId.value = null - return false + retireOwnedEdit(restore) + return true } + editRestorePoint = restore.previousRestorePoint + editGeneration.value += 1 options.pendingForkBeforeMessageId.value = restore.previousForkBeforeMessageId options.messages.value = restore.messages options.inputText.value = restore.inputText @@ -303,11 +351,67 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { 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 + editGeneration.value += 1 + return false + } + if (restoreOwnsCurrentTranscript(restore)) return true + retireOwnedEdit(restore) + 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 + editGeneration.value += 1 + 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) + return false + } + restore.editingMessageOwners.push(...rows.map(message => toRaw(message))) + return true + } + return { copyMessage, regenerateMessage, editMessage, cancelEdit, + validateEditOwner, + adoptRejectedEditRows, editGeneration, } } diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 8b3463391b..24e587252f 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -19,6 +19,7 @@ import type { ChatRenderedMessage, } from '@/types/chat' import type { CollaborationMode } from '@/types/plans' +import type { PromptAnnotationSnapshot } from '@/types/promptAnnotations' import { useChatPendingQueue, type BusySendMode, @@ -230,6 +231,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 { @@ -2210,36 +2272,13 @@ describe('useChatSend attachment payloads', () => { }) it('abandons an edited send when Escape cancels it during project validation', async () => { - const sessionKey = ref('agent:main:webchat:test') - const messages = ref([ - { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, - { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, - ]) - const inputText = ref('unrelated draft') - const pendingForkBeforeMessageId = ref(null) - const messageActions = useChatMessageActions({ + const { 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', - }) + messageActions, + } = makeEditedMessageState() let finishPreflight!: () => void const validateActiveProjectBeforeSend = vi.fn(() => new Promise( @@ -2253,6 +2292,8 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, validateActiveProjectBeforeSend, }) @@ -2271,36 +2312,13 @@ describe('useChatSend attachment payloads', () => { }) it('abandons an edited send when its transcript owner changes during validation', async () => { - const sessionKey = ref('agent:main:webchat:test') - const messages = ref([ - { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, - { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, - ]) - const inputText = ref('unrelated draft') - const pendingForkBeforeMessageId = ref(null) - const messageActions = useChatMessageActions({ + const { 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', - }) + messageActions, + } = makeEditedMessageState() let finishPreflight!: () => void const validateActiveProjectBeforeSend = vi.fn(() => new Promise( @@ -2314,6 +2332,8 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, validateActiveProjectBeforeSend, }) @@ -2325,7 +2345,7 @@ describe('useChatSend attachment payloads', () => { messages.value = replacementTranscript const replacementOwner = messages.value inputText.value = 'replacement owner draft' - expect(messageActions.cancelEdit()).toBe(false) + expect(messageActions.cancelEdit()).toBe(true) expect(messageActions.editGeneration.value).toBe(2) expect(pendingForkBeforeMessageId.value).toBeNull() finishPreflight() @@ -2336,38 +2356,235 @@ describe('useChatSend attachment payloads', () => { expect(inputText.value).toBe('replacement owner draft') }) - it('does not start async queue persistence for a fork edit while work is active', async () => { - const sessionKey = ref('agent:main:webchat:test') - const messages = ref([ - { role: 'user', text: 'original question', ts: null, messageId: 'msg-original' }, - { role: 'assistant', text: 'original answer', ts: null, messageId: 'msg-answer' }, - ]) - const inputText = ref('unrelated draft') - const pendingForkBeforeMessageId = ref(null) - const messageActions = useChatMessageActions({ + 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, - isStreaming: ref(false), - sanitizeCopyText: text => text, - stripTimePrefix: text => text, - autoResizeTextarea: vi.fn(), - sendCurrentInput: vi.fn(), - sendUsageBarrierReplay: vi.fn(async () => false), - focusComposer: vi.fn(), pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + validateActiveProjectBeforeSend, }) - messageActions.editMessage({ - role: 'user', - displayRole: 'user', - roleLabel: 'User', - text: 'original question', - timeStr: '', - showHeader: false, - sourceIndex: 0, - messageId: 'msg-original', + + 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('exact-replays an unknown edited fork only while its transcript owner is unchanged', 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, + validateMessageEditOwner: messageActions.validateEditOwner, + 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' + // The live stream belongs to this request whose acceptance is unknown. + // An exact receipt replay may resolve it, but must not restart or clear it. + 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', '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', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:test', + task_id: 'task-recovered', + })], + ['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 rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockImplementationOnce(replayResult), + } + const { api, options } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + promptAnnotationIds, + messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + const originalRequestId = rpc.call.mock.calls[0]?.[1]?.clientRequestId + 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.txt', + mime: 'text/plain', + 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' + + 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(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('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) @@ -2378,6 +2595,8 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, acceptanceStopPending: ref(true), enqueuePendingInput, }) @@ -2396,6 +2615,350 @@ describe('useChatSend attachment payloads', () => { 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 + let handoffWrites = 0 + const pendingInputWal: PendingInputWal = { + ...memoryHandoffWal(), + putHandoff: vi.fn(() => { + handoffWrites += 1 + if (handoffWrites > 1) return Promise.resolve() + return new Promise(resolve => { + finishHandoff = resolve + }) + }), + } + 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.putHandoff).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, + validateMessageEditOwner: messageActions.validateEditOwner, + 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, + 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, + 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(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('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, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + await api.onSend() + const committedOwner = messages.value + + 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'), + ]) + }) + + 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('database busy'), { + accepted: false, + retryable: true, + })) + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: sessionKey.value, + task_id: 'task-replayed', + }), + } + const { api } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageEditGeneration: messageActions.editGeneration, + validateMessageEditOwner: messageActions.validateEditOwner, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + 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' + + 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']) + + 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 () => { const originalAttachment: Attachment = { kind: 'staged', @@ -4148,9 +4711,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' @@ -4214,10 +4777,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. diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index cb0fdbf37a..493e7879e6 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,6 +181,14 @@ 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 // A Stop issued before durable acceptance is known belongs to this exact @@ -246,7 +254,7 @@ interface DispatchSendOptions { /** 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. */ @@ -498,6 +506,13 @@ export interface UseChatSendOptions { composerRevision?: Readonly> /** Invalidates a composer send when its message-edit owner is cancelled or replaced. */ messageEditGeneration?: Readonly> + /** Confirms that an edit still owns the exact live transcript before mutation. */ + validateMessageEditOwner?: (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> @@ -744,10 +759,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 } } } @@ -798,9 +814,93 @@ export function useChatSend(options: UseChatSendOptions) { } } - function messageEditOwnerMatchesSnapshot(snapshot: ComposerSnapshot): boolean { - return snapshot.messageEditGeneration === null - || options.messageEditGeneration?.value === snapshot.messageEditGeneration + function messageEditOwnerMatchesSnapshot( + snapshot: ComposerSnapshot, + validateTranscript = false, + ): boolean { + if (snapshot.messageEditGeneration === null) return true + 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 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 { @@ -1901,7 +2001,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) { @@ -2289,6 +2392,10 @@ export function useChatSend(options: UseChatSendOptions) { ? recoveredAttempt : null if (exactReplayAttempt) { + const preserveUnrelatedBranch = Boolean( + composerSnapshot.forkBeforeMessageId + && composerSnapshot.forkBeforeMessageId !== exactReplayAttempt.forkBeforeMessageId, + ) const replayBlockedReason = options.idempotentReplayBlockedReason || options.sendBlockedReason if (replayBlockedReason?.value) return @@ -2297,6 +2404,7 @@ export function useChatSend(options: UseChatSendOptions) { } if (options.sessionKey.value !== requestSessionKey) return if (!messageEditOwnerMatchesSnapshot(composerSnapshot)) return + if (!validateAttemptMessageEditTranscript(exactReplayAttempt)) return if (replayBlockedReason?.value) return await dispatchSend(exactReplayAttempt.text, { composerText, @@ -2304,7 +2412,25 @@ export function useChatSend(options: UseChatSendOptions) { queueMode: exactReplayAttempt.queueMode, retryAttempt: exactReplayAttempt, idempotentReplay: true, - preDispatchGuard: () => messageEditOwnerMatchesSnapshot(composerSnapshot), + // 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, + preDispatchGuard: stage => ( + forkSnapshotPreDispatchAllowed( + composerSnapshot, + 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, + }, + ) + && validateAttemptMessageEditTranscript(exactReplayAttempt) + ), }) return } @@ -2319,7 +2445,7 @@ export function useChatSend(options: UseChatSendOptions) { if (await refreshedActiveProjectBlocksSend()) return } if (options.sessionKey.value !== requestSessionKey) return - if (!messageEditOwnerMatchesSnapshot(composerSnapshot)) return + if (!forkSnapshotPreDispatchAllowed(composerSnapshot, 'preflight')) return if (!queueOwnerMatchesSnapshot(composerSnapshot)) return if (options.sendBlockedReason?.value) return if ( @@ -2362,7 +2488,10 @@ export function useChatSend(options: UseChatSendOptions) { payload: payloadFromSnapshot(composerSnapshot), composerSnapshot, cancelIfComposerChanged: invocation.cancelIfComposerChanged, - preDispatchGuard: () => messageEditOwnerMatchesSnapshot(composerSnapshot), + preDispatchGuard: stage => forkSnapshotPreDispatchAllowed( + composerSnapshot, + stage, + ), }) return } @@ -2375,7 +2504,7 @@ export function useChatSend(options: UseChatSendOptions) { if (slashClassification !== null) { if ( options.sessionKey.value !== requestSessionKey - || !messageEditOwnerMatchesSnapshot(composerSnapshot) + || !forkSnapshotPreDispatchAllowed(composerSnapshot, 'preflight') || !composerMatchesSnapshot(composerSnapshot) || !queueOwnerMatchesSnapshot(composerSnapshot) || Boolean(options.sendBlockedReason?.value) @@ -2387,7 +2516,7 @@ export function useChatSend(options: UseChatSendOptions) { ) return if ( options.sessionKey.value !== requestSessionKey - || !messageEditOwnerMatchesSnapshot(composerSnapshot) + || !forkSnapshotPreDispatchAllowed(composerSnapshot, 'preflight') || !composerMatchesSnapshot(composerSnapshot) || !queueOwnerMatchesSnapshot(composerSnapshot) || Boolean(options.sendBlockedReason?.value) @@ -2509,7 +2638,10 @@ export function useChatSend(options: UseChatSendOptions) { payload: payloadFromSnapshot(composerSnapshot), composerSnapshot, cancelIfComposerChanged: invocation.cancelIfComposerChanged, - preDispatchGuard: () => messageEditOwnerMatchesSnapshot(composerSnapshot), + preDispatchGuard: stage => forkSnapshotPreDispatchAllowed( + composerSnapshot, + stage, + ), }) } @@ -2717,7 +2849,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 @@ -2756,6 +2888,8 @@ 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 retryCandidateOwnsTranscript = !retryCandidate + || attemptOwnsMessageEditTranscript(retryCandidate) const requestedPromptAnnotationIds = sendOpts.promptAnnotationIds === undefined ? currentPromptAnnotationIds() : [...sendOpts.promptAnnotationIds] @@ -2764,6 +2898,7 @@ export function useChatSend(options: UseChatSendOptions) { .slice(0, 16) const requiresRecoveryReplay = Boolean( retryCandidate?.requiresIdempotentReplay + && retryCandidateOwnsTranscript && retryCandidate.requestSessionKey === requestSessionKey && retryCandidate.queueMode === sendOpts.queueMode, ) @@ -2771,6 +2906,7 @@ export function useChatSend(options: UseChatSendOptions) { requiresRecoveryReplay || ( retryCandidate + && retryCandidateOwnsTranscript && matchesRecoveredDraft(retryCandidate, { requestSessionKey, promptAnnotationIds: requestedPromptAnnotationIds, @@ -2793,7 +2929,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' } @@ -2905,6 +3041,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 @@ -2998,6 +3162,17 @@ export function useChatSend(options: UseChatSendOptions) { ...(sendOpts.replayCoordination ? { replayCoordinationKey: sendOpts.replayCoordination.key } : {}), + ...(forkBeforeMessageId && 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) { @@ -3013,7 +3188,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, @@ -3022,7 +3197,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() } @@ -3037,7 +3215,7 @@ export function useChatSend(options: UseChatSendOptions) { } if (!preDispatchAllowed()) return rejectBeforeDispatch() } - if (!preDispatchAllowed()) return rejectBeforeDispatch() + if (!preDispatchAllowed('after_mutation')) return rejectBeforeDispatch() if (!preserveComposer) options.closeSlashMenu() recordSessionNavigationDiag('send.start', { requestSession: requestSessionKey, @@ -3066,11 +3244,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() + if (!preDispatchAllowed('after_mutation')) return rejectBeforeDispatch() const freshSendToken = wasStreaming ? null : beginFreshStream(requestSessionKey, attempt) - if (!preDispatchAllowed('before_rpc')) { + if (!preDispatchAllowed(freshSendToken ? 'before_rpc' : 'after_mutation')) { if (freshSendToken && activeFreshSendToken === freshSendToken) { activeFreshSendToken = null options.activeStreamTaskId.value = '' @@ -3091,7 +3269,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 @@ -3300,7 +3478,9 @@ 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 && attemptOwnsMessageEditTranscript(attempt)) { + setAttemptPromptAnnotations(attempt, []) + } if (acceptedError && !commitAcceptedVisibleReplay({ messageId: acceptedError.messageId, })) { @@ -3400,7 +3580,12 @@ export function useChatSend(options: UseChatSendOptions) { if (responseHandoff && acceptedSessionKey === requestSessionKey) { await handoffResponseSession(requestSessionKey, responseHandoff) } - bindUserMessageId(attempt.clientMessageId, acceptedError.messageId) + if ( + !attempt.messageEditTranscriptOwner + || attemptOwnsMessageEditTranscript(attempt) + ) { + bindUserMessageId(attempt.clientMessageId, acceptedError.messageId) + } options.scheduleHistorySync() } if (options.sessionKey.value !== requestSessionKey) { @@ -3431,14 +3616,29 @@ export function useChatSend(options: UseChatSendOptions) { await markResponseHandoffFailed(responseHandoff, err) } } + if ( + 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' + } rememberRetryableAttempt(true) if (acceptedError || !sendOpts.suppressRejectedFailureMessage) { - options.messages.value.push({ + 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 { diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index f4f8bbb0e7..648c00413d 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -2570,6 +2570,8 @@ const { regenerateMessage, editMessage, cancelEdit, + validateEditOwner, + adoptRejectedEditRows, editGeneration, } = chatMessageActions @@ -3343,6 +3345,8 @@ const chatSend = useChatSend({ pendingAttachments, composerRevision, messageEditGeneration: editGeneration, + validateMessageEditOwner: validateEditOwner, + adoptRejectedMessageEditRows: adoptRejectedEditRows, pendingSessionIntent, pendingWorkspaceId, sendBlockedReason: effectiveSendBlockedReason, From a99e82c62edf09ff0b4176d9ca979c519a11bd4a Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 17:39:20 +0800 Subject: [PATCH 07/24] fix(webui): isolate recovered receipt streams --- .../chat/useChatRpcEventHandlers.test.ts | 142 ++++++++++++++++++ .../chat/useChatRpcEventHandlers.ts | 105 ++++++++++++- .../chat/useChatSend.attachments.test.ts | 23 ++- .../src/composables/chat/useChatSend.ts | 55 ++++++- opensquilla-webui/src/views/ChatView.vue | 13 ++ 5 files changed, 330 insertions(+), 8 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 3636591587..158426705f 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { effectScope, nextTick, ref } from 'vue' import { useChatRpcEventHandlers, type ChatRpcStreamApi } from './useChatRpcEventHandlers' +import { useChatMessageActions } from './useChatMessageActions' import type { SessionBootstrapRun } from './useChatSessionBootstrap' import type { ChatMessage, @@ -259,6 +260,147 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { 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, + }) + 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: 'idle', + changed_task: { + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + status: 'succeeded', + }, + last_task: { + task_id: 'task-old-receipt', + client_message_id: 'client-old-receipt', + status: 'succeeded', + }, + }, + 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, + }) + + 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).not.toHaveBeenCalled() + 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() + } 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..83101303b6 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -412,6 +412,94 @@ interface TurnActivityRecord { } export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) { + const pendingBackgroundReceiptClientIds = new Set() + const backgroundReceiptTaskOwners = new Map() + + function beginBackgroundReceiptReplay(clientMessageId: string) { + const normalizedClientId = String(clientMessageId || '').trim() + if (normalizedClientId) pendingBackgroundReceiptClientIds.add(normalizedClientId) + } + + function rememberBackgroundReceiptTask(clientMessageId: string, taskId: string) { + const normalizedClientId = String(clientMessageId || '').trim() + const normalizedTaskId = String(taskId || '').trim() + if (!normalizedClientId || !normalizedTaskId) return + if (!backgroundReceiptTaskOwners.has(normalizedTaskId) && backgroundReceiptTaskOwners.size >= 256) { + const oldestTaskId = backgroundReceiptTaskOwners.keys().next().value + if (typeof oldestTaskId === 'string') backgroundReceiptTaskOwners.delete(oldestTaskId) + } + backgroundReceiptTaskOwners.set(normalizedTaskId, normalizedClientId) + } + + function trackBackgroundReceiptTask(clientMessageId: string, taskId: string) { + rememberBackgroundReceiptTask(clientMessageId, taskId) + } + + function finishBackgroundReceiptReplay(clientMessageId: string) { + pendingBackgroundReceiptClientIds.delete(String(clientMessageId || '').trim()) + } + + function receiptEventIdentity(payload: SessionEventPayload): { + clientMessageId: string + taskId: string + } { + const candidates = [ + payload, + payload.changed_task, + payload.changedTask, + payload.active_task, + payload.activeTask, + payload.last_task, + payload.lastTask, + ] + let clientMessageId = '' + let taskId = payloadTaskId(payload) + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') continue + const record = candidate as Record + clientMessageId ||= String( + record.client_message_id || record.clientMessageId || '', + ).trim() + taskId ||= String(record.task_id || record.taskId || '').trim() + } + return { clientMessageId, taskId } + } + + function suppressBackgroundReceiptEvent( + eventKind: ConversationSemanticEventKind | 'sessions-changed', + payload: SessionEventPayload, + ): boolean { + if (!isCurrentSessionPayload(payload)) return false + const { clientMessageId, taskId } = receiptEventIdentity(payload) + const pendingOwner = clientMessageId + && pendingBackgroundReceiptClientIds.has(clientMessageId) + ? clientMessageId + : '' + const trackedOwner = taskId ? backgroundReceiptTaskOwners.get(taskId) || '' : '' + const owner = pendingOwner || trackedOwner + const suppress = Boolean(owner && taskId) + if (!suppress) return false + // 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') { + options.taskOwnership?.noteQueued({ ...payload, status: 'queued' }) + } else if (eventKind === 'task-running') { + options.taskOwnership?.noteRunning({ ...payload, status: 'running' }) + } else if ( + eventKind === 'sessions-changed' + ? sessionChangeIsTerminal(payload) + : isTerminalEvent(eventKind) + ) { + options.taskOwnership?.noteTerminal(taskId) + } + // Keep the task identity through the complete terminal echo cluster + // (done -> sessions.changed -> task.* / turn.committed). Session change + // retires the owning Edit and clears this bounded registry below. + return true + } + const { sessionKey, currentEpoch, @@ -1357,6 +1445,8 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) } watch(sessionKey, () => { + pendingBackgroundReceiptClientIds.clear() + backgroundReceiptTaskOwners.clear() streamThinking.value = null clearGenerationTracking() turnReasoningLog.length = 0 @@ -2584,7 +2674,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 +2692,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 +2909,8 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) streamThinkingElapsedText, attachTurnReasoning, awaitingCommitTaskIds, + beginBackgroundReceiptReplay, + trackBackgroundReceiptTask, + finishBackgroundReceiptReplay, } } diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 24e587252f..b5b98d78bc 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2479,10 +2479,17 @@ describe('useChatSend attachment payloads', () => { }) it.each([ - ['accepted', () => Promise.resolve({ + ['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, @@ -2510,13 +2517,14 @@ describe('useChatSend attachment payloads', () => { .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) .mockImplementationOnce(replayResult), } - const { api, options } = makeOptions({ + const { api, options, stream } = makeOptions({ rpc, sessionKey, messages, inputText, pendingForkBeforeMessageId, promptAnnotationIds, + modelRoutingMode: ref<'llm_ensemble'>('llm_ensemble'), messageEditGeneration: messageActions.editGeneration, validateMessageEditOwner: messageActions.validateEditOwner, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, @@ -2524,6 +2532,8 @@ describe('useChatSend attachment payloads', () => { await api.onSend() const originalRequestId = rpc.call.mock.calls[0]?.[1]?.clientRequestId + expect(stream.startStreaming).toHaveBeenCalledTimes(1) + expect(stream.endStreaming).toHaveBeenCalledTimes(1) expect(messages.value.map(message => message.role)).toEqual([ 'user', 'assistant', 'user', 'error', ]) @@ -2543,8 +2553,8 @@ describe('useChatSend attachment payloads', () => { const currentAttachment: Attachment = { kind: 'staged', local_id: 901, - name: 'new-edit.txt', - mime: 'text/plain', + name: 'new-edit.png', + mime: 'image/png', file_uuid: 'new-edit-file', } options.pendingAttachments.value = [currentAttachment] @@ -2565,6 +2575,11 @@ describe('useChatSend attachment payloads', () => { 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) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 493e7879e6..d7009dcfe9 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -251,6 +251,8 @@ 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 /** Preserve an explicit empty attachment list on the chat.send wire. */ includeEmptyAttachments?: boolean /** Revalidate protocol-owned sends after every awaited pre-dispatch step. */ @@ -620,6 +622,12 @@ 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) => void + /** Keep a non-terminal accepted receipt task off the visible stream. */ + trackBackgroundReceiptTask?: (clientMessageId: string, taskId: 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 @@ -2410,6 +2418,15 @@ export function useChatSend(options: UseChatSendOptions) { composerText, 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 @@ -2417,6 +2434,7 @@ export function useChatSend(options: UseChatSendOptions) { // visible transcript state belongs to the replay. preserveComposer: preserveUnrelatedBranch, suppressRejectedFailureMessage: preserveUnrelatedBranch, + backgroundReceiptReplay: preserveUnrelatedBranch, preDispatchGuard: stage => ( forkSnapshotPreDispatchAllowed( composerSnapshot, @@ -3244,8 +3262,9 @@ 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 + const backgroundReceiptReplay = sendOpts.backgroundReceiptReplay === true if (!preDispatchAllowed('after_mutation')) return rejectBeforeDispatch() - const freshSendToken = wasStreaming + const freshSendToken = wasStreaming || backgroundReceiptReplay ? null : beginFreshStream(requestSessionKey, attempt) if (!preDispatchAllowed(freshSendToken ? 'before_rpc' : 'after_mutation')) { @@ -3322,11 +3341,30 @@ export function useChatSend(options: UseChatSendOptions) { ) attempt.acceptanceRequest = { request: acceptanceRequest } attempt.acceptanceInFlight = true + if (backgroundReceiptReplay) { + options.beginBackgroundReceiptReplay?.(attempt.clientMessageId) + } const res = await options.turnCommands.send(acceptanceRequest) acknowledgeAttemptPromptAnnotations(attempt, res) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(res) attempt.acceptedSessionKey = res?.sessionKey || requestSessionKey + if (backgroundReceiptReplay) { + const terminalStatus = terminalResponseStatus(res) + const accepted = noteAcceptedTask(res, requestSessionKey) + if (!terminalStatus && accepted.taskId) { + options.trackBackgroundReceiptTask?.(attempt.clientMessageId, accepted.taskId) + } + if (recoveredAttempt?.clientRequestId === attempt.clientRequestId) { + recoveredAttempt = null + } + consumeAcceptedSessionIntent(attempt) + // This request predates the branch currently shown in the composer. + // Quarantine its accepted task offscreen; binding it as the foreground + // stream (or materializing a terminal response) would append into the + // newer edit's exact transcript and make Escape unable to restore it. + return 'accepted' + } if (!commitAcceptedVisibleReplay({ messageId: res?.userMessageId || res?.messageId || '', turnId: acceptedTaskId(res), @@ -3481,9 +3519,13 @@ export function useChatSend(options: UseChatSendOptions) { if (!acceptedError && attemptOwnsMessageEditTranscript(attempt)) { setAttemptPromptAnnotations(attempt, []) } - if (acceptedError && !commitAcceptedVisibleReplay({ + if ( + acceptedError + && !backgroundReceiptReplay + && !commitAcceptedVisibleReplay({ messageId: acceptedError.messageId, - })) { + }) + ) { options.scheduleHistorySync() } if ( @@ -3493,6 +3535,10 @@ export function useChatSend(options: UseChatSendOptions) { recoveredAttempt = null } if (acceptedError) consumeAcceptedSessionIntent(attempt) + if (acceptedError && backgroundReceiptReplay) { + attempt.acceptanceResolved = true + return 'accepted' + } const acceptedSessionKey = acceptedError?.sessionKey || requestSessionKey const rememberRetryableAttempt = (restoreComposer: boolean) => { if (!shouldRestoreSendAttempt(err)) return @@ -3643,6 +3689,9 @@ export function useChatSend(options: UseChatSendOptions) { return acceptedError ? 'accepted' : 'retryable_failure' } finally { attempt.acceptanceInFlight = false + if (backgroundReceiptReplay) { + options.finishBackgroundReceiptReplay?.(attempt.clientMessageId) + } finishAcceptanceTransaction(acceptanceTransaction) finishResponseHandoff(responseHandoff) } diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 648c00413d..a70b727c1f 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1697,6 +1697,9 @@ const isStopPending = computed(() => ( || acceptanceRecoveryPending.value )) let bindActiveStreamTask = (taskId: string) => { activeStreamTaskId.value = taskId } +let beginBackgroundReceiptReplay = (_clientMessageId: string) => {} +let trackBackgroundReceiptTask = (_clientMessageId: string, _taskId: string) => {} +let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} let restoreLiveTurnSnapshot = (_snapshot: SessionReadSnapshot) => {} function projectWorkspaceFromSessionRead( @@ -3446,6 +3449,13 @@ const chatSend = useChatSend({ restoreSteerIntoComposer: text => appendComposerText(text), popAllPendingIntoComposer, reconcileTaskOwnership: () => retrySessionMetadata(), + beginBackgroundReceiptReplay: clientMessageId => beginBackgroundReceiptReplay(clientMessageId), + trackBackgroundReceiptTask: (clientMessageId, taskId) => ( + trackBackgroundReceiptTask(clientMessageId, taskId) + ), + finishBackgroundReceiptReplay: clientMessageId => ( + finishBackgroundReceiptReplay(clientMessageId) + ), classifySlashCommand, executeSlashCommand, closeSlashMenu, @@ -3906,6 +3916,9 @@ const rpcEventHandlers = useChatRpcEventHandlers({ refreshRunModePreference: refreshPostBootstrapMetadata, }) bindActiveStreamTask = rpcEventHandlers.bindActiveStreamTask +beginBackgroundReceiptReplay = rpcEventHandlers.beginBackgroundReceiptReplay +trackBackgroundReceiptTask = rpcEventHandlers.trackBackgroundReceiptTask +finishBackgroundReceiptReplay = rpcEventHandlers.finishBackgroundReceiptReplay restoreLiveTurnSnapshot = rpcEventHandlers.restoreLiveTurnSnapshot const { streamThinkingText, From b17e2ea67245ce0248b1a88bab32a81cd28b6d3b Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 18:22:31 +0800 Subject: [PATCH 08/24] fix(webui): isolate recovered receipts from edits --- .../chat/useChatMessageActions.test.ts | 34 +++ .../composables/chat/useChatMessageActions.ts | 36 +++ .../chat/useChatRpcEventHandlers.test.ts | 29 +- .../chat/useChatRpcEventHandlers.ts | 184 +++++++++--- .../chat/useChatSend.attachments.test.ts | 261 ++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 226 ++++++++++++--- opensquilla-webui/src/views/ChatView.vue | 26 +- 7 files changed, 722 insertions(+), 74 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index 6989fac4a2..54130e10b4 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -359,6 +359,40 @@ describe('useChatMessageActions branching edits', () => { 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' }, diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index 0b7b76d31f..890fc53a75 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -48,6 +48,10 @@ 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 { @@ -72,12 +76,14 @@ interface EditRestorePoint { 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, () => { editRestorePoint = null + editActive.value = false editGeneration.value += 1 }, { flush: 'sync' }) @@ -97,6 +103,7 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { function retireOwnedEdit(restore: EditRestorePoint): void { editRestorePoint = null + editActive.value = false editGeneration.value += 1 if ( options.sessionKey.value === restore.sessionKey @@ -289,6 +296,7 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { 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 @@ -305,6 +313,10 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { forkBeforeMessageId, previousRestorePoint: editRestorePoint, } + if (startsEditIsolation) { + editActive.value = true + options.onEditStarted?.() + } options.pendingForkBeforeMessageId.value = forkBeforeMessageId options.messages.value = editingMessages options.inputText.value = text @@ -331,7 +343,9 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { // 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)) { @@ -340,14 +354,28 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { // 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 } @@ -362,11 +390,14 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { 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 } @@ -384,7 +415,9 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { if (!restore) return false if (!restoreOwnsCurrentSessionAndFork(restore)) { editRestorePoint = null + editActive.value = false editGeneration.value += 1 + options.onEditSettled?.() return false } const currentMessages = options.messages.value @@ -399,6 +432,7 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { )) if (!ownsPrefix || !ownsSuffix) { retireOwnedEdit(restore) + options.onEditSettled?.() return false } restore.editingMessageOwners.push(...rows.map(message => toRaw(message))) @@ -410,8 +444,10 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { regenerateMessage, editMessage, cancelEdit, + commitEdit, validateEditOwner, adoptRejectedEditRows, editGeneration, + editActive, } } diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 158426705f..307edf5380 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -293,6 +293,8 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { sendUsageBarrierReplay: vi.fn(async () => true), focusComposer: vi.fn(), pendingForkBeforeMessageId, + onEditStarted: harness.api.holdBackgroundReceiptReconciliation, + onEditSettled: harness.api.releaseBackgroundReceiptReconciliation, }) messageActions.editMessage({ role: 'user', @@ -351,17 +353,20 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { payload: { session_key: 'agent:main:test', reason: 'task_terminal', - run_status: 'idle', + run_status: 'running', changed_task: { task_id: 'task-old-receipt', - client_message_id: 'client-old-receipt', status: 'succeeded', }, last_task: { task_id: 'task-old-receipt', - client_message_id: 'client-old-receipt', status: 'succeeded', }, + active_task: { + task_id: 'task-successor', + client_message_id: 'client-successor', + status: 'running', + }, }, meta: {}, }) @@ -375,12 +380,27 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { 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).not.toHaveBeenCalled() + expect(harness.applySessionRunState).toHaveBeenCalledOnce() + 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() @@ -397,6 +417,7 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { ]) expect(inputText.value).toBe('unrelated draft') expect(pendingForkBeforeMessageId.value).toBeNull() + expect(harness.scheduleHistorySync).toHaveBeenCalledOnce() } finally { harness.stop() } diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index 83101303b6..70de2b6f94 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -412,57 +412,168 @@ interface TurnActivityRecord { } export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) { + interface BackgroundReceiptTask { + clientMessageId: string + terminalSeen: boolean + } + const pendingBackgroundReceiptClientIds = new Set() - const backgroundReceiptTaskOwners = new Map() + const backgroundReceiptClientIds = new Set() + const backgroundReceiptTasks = new Map() + const dirtyBackgroundReceiptClientIds = new Set() + const reconciledBackgroundReceiptClientIds = new Set() + let backgroundReceiptEditHeld = false - function beginBackgroundReceiptReplay(clientMessageId: string) { + function rememberBackgroundReceiptClient(clientMessageId: string) { const normalizedClientId = String(clientMessageId || '').trim() - if (normalizedClientId) pendingBackgroundReceiptClientIds.add(normalizedClientId) + 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) + } + } + backgroundReceiptClientIds.add(normalizedClientId) + } + + function holdBackgroundReceiptReconciliation() { + backgroundReceiptEditHeld = true + } + + function flushBackgroundReceiptReconciliationIfReady() { + if (backgroundReceiptEditHeld || dirtyBackgroundReceiptClientIds.size === 0) return + for (const clientMessageId of dirtyBackgroundReceiptClientIds) { + reconciledBackgroundReceiptClientIds.add(clientMessageId) + } + dirtyBackgroundReceiptClientIds.clear() + options.scheduleHistorySync() } - function rememberBackgroundReceiptTask(clientMessageId: string, taskId: string) { + function releaseBackgroundReceiptReconciliation() { + backgroundReceiptEditHeld = false + flushBackgroundReceiptReconciliationIfReady() + } + + function beginBackgroundReceiptReplay(clientMessageId: string, holdHistory = false) { + const normalizedClientId = String(clientMessageId || '').trim() + if (!normalizedClientId) return + pendingBackgroundReceiptClientIds.add(normalizedClientId) + rememberBackgroundReceiptClient(normalizedClientId) + reconciledBackgroundReceiptClientIds.delete(normalizedClientId) + if (holdHistory) holdBackgroundReceiptReconciliation() + } + + function rememberBackgroundReceiptTask( + clientMessageId: string, + taskId: string, + terminalSeen = false, + ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() if (!normalizedClientId || !normalizedTaskId) return - if (!backgroundReceiptTaskOwners.has(normalizedTaskId) && backgroundReceiptTaskOwners.size >= 256) { - const oldestTaskId = backgroundReceiptTaskOwners.keys().next().value - if (typeof oldestTaskId === 'string') backgroundReceiptTaskOwners.delete(oldestTaskId) - } - backgroundReceiptTaskOwners.set(normalizedTaskId, normalizedClientId) + 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) + } + backgroundReceiptTasks.set(normalizedTaskId, { + clientMessageId: normalizedClientId, + terminalSeen: terminalSeen || existing?.terminalSeen === true, + }) } - function trackBackgroundReceiptTask(clientMessageId: string, taskId: string) { - rememberBackgroundReceiptTask(clientMessageId, taskId) + function trackBackgroundReceiptTask( + clientMessageId: string, + taskId: string, + terminal = false, + ) { + const normalizedClientId = String(clientMessageId || '').trim() + rememberBackgroundReceiptClient(normalizedClientId) + rememberBackgroundReceiptTask(normalizedClientId, taskId, terminal) + if (terminal && !reconciledBackgroundReceiptClientIds.has(normalizedClientId)) { + dirtyBackgroundReceiptClientIds.add(normalizedClientId) + flushBackgroundReceiptReconciliationIfReady() + } } function finishBackgroundReceiptReplay(clientMessageId: string) { pendingBackgroundReceiptClientIds.delete(String(clientMessageId || '').trim()) } - function receiptEventIdentity(payload: SessionEventPayload): { + function receiptEventIdentities(payload: SessionEventPayload): Array<{ clientMessageId: string taskId: string - } { + }> { const candidates = [ payload, payload.changed_task, payload.changedTask, - payload.active_task, - payload.activeTask, payload.last_task, payload.lastTask, + payload.active_task, + payload.activeTask, ] - let clientMessageId = '' - let taskId = payloadTaskId(payload) + const identities: Array<{ clientMessageId: string, taskId: string }> = [] for (const candidate of candidates) { if (!candidate || typeof candidate !== 'object') continue const record = candidate as Record - clientMessageId ||= String( + const clientMessageId = String( record.client_message_id || record.clientMessageId || '', ).trim() - taskId ||= String(record.task_id || record.taskId || '').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 { clientMessageId, taskId } + return identities + } + + function matchingBackgroundReceiptIdentity(payload: SessionEventPayload): { + clientMessageId: string + taskId: string + } | null { + for (const identity of receiptEventIdentities(payload)) { + const tracked = backgroundReceiptTasks.get(identity.taskId) + if (tracked) { + return { clientMessageId: tracked.clientMessageId, taskId: identity.taskId } + } + if ( + identity.clientMessageId + && backgroundReceiptClientIds.has(identity.clientMessageId) + ) return identity + } + return null + } + + function applyBackgroundReceiptContinuation( + payload: SessionEventPayload, + receiptTaskId: string, + ) { + 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 + 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) } function suppressBackgroundReceiptEvent( @@ -470,15 +581,9 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) payload: SessionEventPayload, ): boolean { if (!isCurrentSessionPayload(payload)) return false - const { clientMessageId, taskId } = receiptEventIdentity(payload) - const pendingOwner = clientMessageId - && pendingBackgroundReceiptClientIds.has(clientMessageId) - ? clientMessageId - : '' - const trackedOwner = taskId ? backgroundReceiptTaskOwners.get(taskId) || '' : '' - const owner = pendingOwner || trackedOwner - const suppress = Boolean(owner && taskId) - if (!suppress) return false + const identity = matchingBackgroundReceiptIdentity(payload) + if (!identity) return false + const { clientMessageId: owner, taskId } = identity // 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. @@ -493,10 +598,19 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) : isTerminalEvent(eventKind) ) { options.taskOwnership?.noteTerminal(taskId) + rememberBackgroundReceiptTask(owner, taskId, true) + if (!reconciledBackgroundReceiptClientIds.has(owner)) { + dirtyBackgroundReceiptClientIds.add(owner) + } + flushBackgroundReceiptReconciliationIfReady() } // Keep the task identity through the complete terminal echo cluster - // (done -> sessions.changed -> task.* / turn.committed). Session change - // retires the owning Edit and clears this bounded registry below. + // (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. + if (eventKind === 'sessions-changed') { + applyBackgroundReceiptContinuation(payload, taskId) + } return true } @@ -1446,7 +1560,11 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) watch(sessionKey, () => { pendingBackgroundReceiptClientIds.clear() - backgroundReceiptTaskOwners.clear() + backgroundReceiptClientIds.clear() + backgroundReceiptTasks.clear() + dirtyBackgroundReceiptClientIds.clear() + reconciledBackgroundReceiptClientIds.clear() + backgroundReceiptEditHeld = false streamThinking.value = null clearGenerationTracking() turnReasoningLog.length = 0 @@ -2912,5 +3030,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) 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 b5b98d78bc..81ad0b9423 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2292,7 +2292,9 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, validateActiveProjectBeforeSend, }) @@ -2332,7 +2334,9 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, validateActiveProjectBeforeSend, }) @@ -2428,7 +2432,9 @@ describe('useChatSend attachment payloads', () => { promptAnnotationIds: ref(['annotation-edit']), promptAnnotationSnapshots: () => [annotation], messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) @@ -2512,6 +2518,7 @@ describe('useChatSend attachment payloads', () => { 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)) @@ -2526,12 +2533,16 @@ describe('useChatSend attachment payloads', () => { 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([ @@ -2562,6 +2573,11 @@ describe('useChatSend attachment payloads', () => { promptAnnotationIds.value = ['current-edit-annotation'] options.pendingSessionIntent.value = 'new_chat' + expect(beginBackgroundReceiptReplay).toHaveBeenCalledWith( + originalClientMessageId, + true, + ) + await api.onSend() expect(rpc.call).toHaveBeenCalledTimes(2) @@ -2591,6 +2607,144 @@ describe('useChatSend attachment payloads', () => { }, ) + 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.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('does not start async queue persistence for a fork edit while work is active', async () => { const { sessionKey, @@ -2610,7 +2764,9 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, acceptanceStopPending: ref(true), enqueuePendingInput, @@ -2750,7 +2906,9 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) @@ -2876,6 +3034,99 @@ describe('useChatSend attachment payloads', () => { expect(messageActions.cancelEdit()).toBe(false) }) + 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, + }) + + 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' + + finishSend() + await send + + 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, @@ -2900,7 +3151,9 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) @@ -2916,6 +3169,14 @@ describe('useChatSend attachment payloads', () => { 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('starts a fresh receipt when the same edit is re-entered after cancellation', async () => { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index d7009dcfe9..8047374a04 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -508,8 +508,12 @@ export interface UseChatSendOptions { 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, @@ -623,9 +627,13 @@ export interface UseChatSendOptions { popAllPendingIntoComposer: () => boolean reconcileTaskOwnership?: () => void | Promise /** Bracket an older receipt replay so its early live events stay offscreen. */ - beginBackgroundReceiptReplay?: (clientMessageId: string) => void + beginBackgroundReceiptReplay?: (clientMessageId: string, holdHistory?: boolean) => void /** Keep a non-terminal accepted receipt task off the visible stream. */ - trackBackgroundReceiptTask?: (clientMessageId: string, taskId: string) => void + trackBackgroundReceiptTask?: ( + clientMessageId: string, + taskId: string, + terminal?: boolean, + ) => void /** Release the pre-response event quarantine for an older receipt replay. */ finishBackgroundReceiptReplay?: (clientMessageId: string) => void hiddenControlStorage?: HiddenControlStorage | null @@ -708,6 +716,7 @@ export function useChatSend(options: UseChatSendOptions) { function acknowledgeAttemptPromptAnnotations( attempt: SendAttempt, response: TurnSendResponse, + updateVisibleMessage = true, ) { if ( attempt.promptAnnotationIds.length === 0 @@ -730,7 +739,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 @@ -822,6 +831,60 @@ export function useChatSend(options: UseChatSendOptions) { } } + function recoveredAttemptHasUnrelatedComposer( + attempt: SendAttempt, + snapshot: ComposerSnapshot, + ): boolean { + const editOwner = attempt.messageEditTranscriptOwner + if (editOwner) { + return !( + options.messageEditActive?.value === true + && snapshot.messageEditGeneration === editOwner.generation + && attemptOwnsMessageEditTranscript(attempt) + ) + } + 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, + ) + } + + 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, + ], quarantineRecoveredAttemptIfUnrelated, { flush: 'sync', deep: true }) + function messageEditOwnerMatchesSnapshot( snapshot: ComposerSnapshot, validateTranscript = false, @@ -876,6 +939,18 @@ export function useChatSend(options: UseChatSendOptions) { ) } + 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 @@ -1216,10 +1291,23 @@ export function useChatSend(options: UseChatSendOptions) { attempt: SendAttempt, response: TurnSendResponse, ): Promise { - acknowledgeAttemptPromptAnnotations(attempt, response) + let responseOwnsVisibleTranscript = !recoveredAttemptHasUnrelatedComposer( + attempt, + captureComposerSnapshot(), + ) && validateAttemptMessageEditTranscript(attempt) + acknowledgeAttemptPromptAnnotations(attempt, response, responseOwnsVisibleTranscript) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(response) attempt.acceptedSessionKey = response.sessionKey || attempt.requestSessionKey + if ( + responseOwnsVisibleTranscript + && attempt.messageEditTranscriptOwner + && options.commitMessageEdit?.( + attempt.messageEditTranscriptOwner.generation, + ) === false + ) { + responseOwnsVisibleTranscript = false + } const ownsRecoveredAttempt = recoveredAttempt?.clientRequestId === attempt.clientRequestId if (attempt.hiddenControl) { removeHiddenControl( @@ -1232,10 +1320,17 @@ export function useChatSend(options: UseChatSendOptions) { const isCurrentRequest = options.sessionKey.value === attempt.requestSessionKey const accepted = noteAcceptedTask(response, attempt.requestSessionKey) const terminalStatus = terminalResponseStatus(response) - if (isCurrentRequest) { + if (isCurrentRequest && responseOwnsVisibleTranscript) { consumeAcceptedSessionIntent(attempt) bindAcceptedUserMessage(attempt.clientMessageId, response) options.scheduleHistorySync() + } else if (isCurrentRequest) { + consumeAcceptedSessionIntent(attempt) + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + accepted.taskId, + Boolean(terminalStatus), + ) } if (!attempt.stopRequested) { @@ -1243,7 +1338,7 @@ export function useChatSend(options: UseChatSendOptions) { return true } if (terminalStatus) { - if (isCurrentRequest) { + if (isCurrentRequest && responseOwnsVisibleTranscript) { handleTerminalResponse(response, null, { finishFreshStream: false }) } clearAttemptStop(attempt) @@ -2400,10 +2495,6 @@ export function useChatSend(options: UseChatSendOptions) { ? recoveredAttempt : null if (exactReplayAttempt) { - const preserveUnrelatedBranch = Boolean( - composerSnapshot.forkBeforeMessageId - && composerSnapshot.forkBeforeMessageId !== exactReplayAttempt.forkBeforeMessageId, - ) const replayBlockedReason = options.idempotentReplayBlockedReason || options.sendBlockedReason if (replayBlockedReason?.value) return @@ -2411,11 +2502,17 @@ export function useChatSend(options: UseChatSendOptions) { if (await refreshedActiveProjectBlocksSend()) return } if (options.sessionKey.value !== requestSessionKey) return - if (!messageEditOwnerMatchesSnapshot(composerSnapshot)) return + const replayComposerSnapshot = captureComposerSnapshot() + const preserveUnrelatedBranch = recoveredAttemptHasUnrelatedComposer( + exactReplayAttempt, + replayComposerSnapshot, + ) + if (!messageEditOwnerMatchesSnapshot(replayComposerSnapshot)) return if (!validateAttemptMessageEditTranscript(exactReplayAttempt)) return if (replayBlockedReason?.value) return await dispatchSend(exactReplayAttempt.text, { composerText, + composerSnapshot: replayComposerSnapshot, promptAnnotationIds: exactReplayAttempt.promptAnnotationIds, queueMode: exactReplayAttempt.queueMode, payload: { @@ -2437,7 +2534,7 @@ export function useChatSend(options: UseChatSendOptions) { backgroundReceiptReplay: preserveUnrelatedBranch, preDispatchGuard: stage => ( forkSnapshotPreDispatchAllowed( - composerSnapshot, + replayComposerSnapshot, stage, { forkBeforeMessageId: exactReplayAttempt.forkBeforeMessageId, @@ -3027,6 +3124,12 @@ export function useChatSend(options: UseChatSendOptions) { : false if (sendOpts.cancelIfComposerChanged && composerChanged) return 'not_sent' if (composerChanged) preserveComposer = true + const backgroundReceiptReplay = sendOpts.backgroundReceiptReplay === true + || Boolean( + sendOpts.idempotentReplay + && preserveComposer + && retryAttempt?.requiresIdempotentReplay, + ) const currentSourceAttachments = sendOpts.payload?.attachments ?? options.pendingAttachments.value if ( @@ -3253,7 +3356,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), @@ -3262,7 +3365,6 @@ 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 - const backgroundReceiptReplay = sendOpts.backgroundReceiptReplay === true if (!preDispatchAllowed('after_mutation')) return rejectBeforeDispatch() const freshSendToken = wasStreaming || backgroundReceiptReplay ? null @@ -3342,27 +3444,48 @@ export function useChatSend(options: UseChatSendOptions) { attempt.acceptanceRequest = { request: acceptanceRequest } attempt.acceptanceInFlight = true if (backgroundReceiptReplay) { - options.beginBackgroundReceiptReplay?.(attempt.clientMessageId) + 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.acceptedSessionKey = res?.sessionKey || requestSessionKey - if (backgroundReceiptReplay) { + if ( + responseOwnsVisibleTranscript + && attempt.messageEditTranscriptOwner + && options.commitMessageEdit?.( + attempt.messageEditTranscriptOwner.generation, + ) === false + ) { + responseOwnsVisibleTranscript = false + } + if (!responseOwnsVisibleTranscript) { const terminalStatus = terminalResponseStatus(res) const accepted = noteAcceptedTask(res, requestSessionKey) - if (!terminalStatus && accepted.taskId) { - options.trackBackgroundReceiptTask?.(attempt.clientMessageId, accepted.taskId) - } + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + accepted.taskId, + Boolean(terminalStatus), + ) if (recoveredAttempt?.clientRequestId === attempt.clientRequestId) { recoveredAttempt = null } consumeAcceptedSessionIntent(attempt) - // This request predates the branch currently shown in the composer. - // Quarantine its accepted task offscreen; binding it as the foreground - // stream (or materializing a terminal response) would append into the - // newer edit's exact transcript and make Escape unable to restore it. + 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({ @@ -3504,6 +3627,7 @@ export function useChatSend(options: UseChatSendOptions) { terminalStatus && responseIsCurrent && options.sessionKey.value === terminalSessionKey + && attemptTranscriptIdentityStillOwned(attempt) ) { handleTerminalResponse(res, freshSendToken, { finishFreshStream: !wasStreaming, @@ -3516,14 +3640,29 @@ export function useChatSend(options: UseChatSendOptions) { } catch (err: unknown) { const rpcError = err as RpcClientError | null | undefined const acceptedError = acceptedErrorInfo(err) + 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 - && !backgroundReceiptReplay + && acceptedResponseOwnsVisibleTranscript && !commitAcceptedVisibleReplay({ - messageId: acceptedError.messageId, + messageId: acceptedError.messageId, }) ) { options.scheduleHistorySync() @@ -3535,8 +3674,15 @@ export function useChatSend(options: UseChatSendOptions) { recoveredAttempt = null } if (acceptedError) consumeAcceptedSessionIntent(attempt) - if (acceptedError && backgroundReceiptReplay) { + if (acceptedError && !acceptedResponseOwnsVisibleTranscript) { attempt.acceptanceResolved = true + options.trackBackgroundReceiptTask?.(attempt.clientMessageId, '', true) + if (!wasStreaming && freshSendToken && activeFreshSendToken === freshSendToken) { + activeFreshSendToken = null + options.activeStreamTaskId.value = '' + options.activeStreamSessionKey.value = '' + options.stream.endStreaming() + } return 'accepted' } const acceptedSessionKey = acceptedError?.sessionKey || requestSessionKey @@ -3549,12 +3695,14 @@ export function useChatSend(options: UseChatSendOptions) { sendOpts.rememberRetryableAttempt(attempt) } else { 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. recoveredAttempt = attempt + quarantineRecoveredAttemptIfUnrelated() } else if (restoreComposer) { restoreSendAttempt(attempt, { requiresIdempotentReplay: false, @@ -3626,12 +3774,18 @@ export function useChatSend(options: UseChatSendOptions) { if (responseHandoff && acceptedSessionKey === requestSessionKey) { await handoffResponseSession(requestSessionKey, responseHandoff) } - if ( - !attempt.messageEditTranscriptOwner - || attemptOwnsMessageEditTranscript(attempt) - ) { - bindUserMessageId(attempt.clientMessageId, acceptedError.messageId) + if (!attemptTranscriptIdentityStillOwned(attempt)) { + attempt.acceptanceResolved = true + options.trackBackgroundReceiptTask?.(attempt.clientMessageId, '', true) + 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 (options.sessionKey.value !== requestSessionKey) { @@ -3663,6 +3817,8 @@ export function useChatSend(options: UseChatSendOptions) { } } if ( + !acceptedError + && attempt.messageEditTranscriptOwner && !validateAttemptMessageEditTranscript(attempt) ) { @@ -3673,7 +3829,10 @@ export function useChatSend(options: UseChatSendOptions) { return acceptedError ? 'accepted' : 'retryable_failure' } rememberRetryableAttempt(true) - if (acceptedError || !sendOpts.suppressRejectedFailureMessage) { + if ( + acceptedError + || (!backgroundReceiptReplay && !sendOpts.suppressRejectedFailureMessage) + ) { const errorRow: ChatMessage = { role: 'error', text: sendFailureMessage(err, paramsHaveArtifactContext(attempt.params)), @@ -3830,6 +3989,7 @@ export function useChatSend(options: UseChatSendOptions) { } attempt.requiresIdempotentReplay = recovery.requiresIdempotentReplay recoveredAttempt = attempt + quarantineRecoveredAttemptIfUnrelated() options.autoResizeTextarea() } diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index a70b727c1f..3b852f7398 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1697,9 +1697,15 @@ const isStopPending = computed(() => ( || acceptanceRecoveryPending.value )) let bindActiveStreamTask = (taskId: string) => { activeStreamTaskId.value = taskId } -let beginBackgroundReceiptReplay = (_clientMessageId: string) => {} -let trackBackgroundReceiptTask = (_clientMessageId: string, _taskId: string) => {} +let beginBackgroundReceiptReplay = (_clientMessageId: string, _holdHistory = false) => {} +let trackBackgroundReceiptTask = ( + _clientMessageId: string, + _taskId: string, + _terminal = false, +) => {} let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} +let holdBackgroundReceiptReconciliation = () => {} +let releaseBackgroundReceiptReconciliation = () => {} let restoreLiveTurnSnapshot = (_snapshot: SessionReadSnapshot) => {} function projectWorkspaceFromSessionRead( @@ -2567,15 +2573,19 @@ const chatMessageActions = useChatMessageActions({ }, notifyMessagePending: () => pushToast(t('chat.toast.messageStillSaving'), { tone: 'info' }), notifyEditBlocked: () => pushToast(t('chat.pending.editWhileStreaming'), { tone: 'info' }), + onEditStarted: () => holdBackgroundReceiptReconciliation(), + onEditSettled: () => releaseBackgroundReceiptReconciliation(), }) const { copyMessage, regenerateMessage, editMessage, cancelEdit, + commitEdit, validateEditOwner, adoptRejectedEditRows, editGeneration, + editActive, } = chatMessageActions async function handleRegenerateMessage( @@ -3348,7 +3358,9 @@ const chatSend = useChatSend({ pendingAttachments, composerRevision, messageEditGeneration: editGeneration, + messageEditActive: editActive, validateMessageEditOwner: validateEditOwner, + commitMessageEdit: commitEdit, adoptRejectedMessageEditRows: adoptRejectedEditRows, pendingSessionIntent, pendingWorkspaceId, @@ -3449,9 +3461,11 @@ const chatSend = useChatSend({ restoreSteerIntoComposer: text => appendComposerText(text), popAllPendingIntoComposer, reconcileTaskOwnership: () => retrySessionMetadata(), - beginBackgroundReceiptReplay: clientMessageId => beginBackgroundReceiptReplay(clientMessageId), - trackBackgroundReceiptTask: (clientMessageId, taskId) => ( - trackBackgroundReceiptTask(clientMessageId, taskId) + beginBackgroundReceiptReplay: (clientMessageId, holdHistory) => ( + beginBackgroundReceiptReplay(clientMessageId, holdHistory) + ), + trackBackgroundReceiptTask: (clientMessageId, taskId, terminal) => ( + trackBackgroundReceiptTask(clientMessageId, taskId, terminal) ), finishBackgroundReceiptReplay: clientMessageId => ( finishBackgroundReceiptReplay(clientMessageId) @@ -3919,6 +3933,8 @@ bindActiveStreamTask = rpcEventHandlers.bindActiveStreamTask beginBackgroundReceiptReplay = rpcEventHandlers.beginBackgroundReceiptReplay trackBackgroundReceiptTask = rpcEventHandlers.trackBackgroundReceiptTask finishBackgroundReceiptReplay = rpcEventHandlers.finishBackgroundReceiptReplay +holdBackgroundReceiptReconciliation = rpcEventHandlers.holdBackgroundReceiptReconciliation +releaseBackgroundReceiptReconciliation = rpcEventHandlers.releaseBackgroundReceiptReconciliation restoreLiveTurnSnapshot = rpcEventHandlers.restoreLiveTurnSnapshot const { streamThinkingText, From e886fc8926c5f242140c6635b4947a603e073831 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 20:35:59 +0800 Subject: [PATCH 09/24] fix(webui): fence edit-owned history recovery --- .../composables/chat/useChatHistory.test.ts | 94 +++++++++++++++++++ .../src/composables/chat/useChatHistory.ts | 37 ++++++++ .../chat/useChatRpcEventHandlers.test.ts | 56 +++++++++++ .../chat/useChatRpcEventHandlers.ts | 4 +- .../chat/useChatSend.attachments.test.ts | 91 +++++++++++++++++- .../src/composables/chat/useChatSend.ts | 38 +++++++- opensquilla-webui/src/views/ChatView.vue | 12 ++- 7 files changed, 324 insertions(+), 8 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts index 32d4a8f0e4..14f88998f1 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts @@ -2405,6 +2405,100 @@ 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('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..9453b04030 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.ts @@ -729,6 +729,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { let historySyncPending = false let historySyncTimerNonReconnecting = false let historySyncPendingNonReconnecting = false + let historySyncHeld = false // 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 +782,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 +806,23 @@ export function useChatHistory(options: UseChatHistoryOptions) { armHistorySync(preserveLocalTail, true) } + function holdHistorySync() { + historySyncHeld = true + if (!historySyncTimer) return + clearTimeout(historySyncTimer) + historySyncTimer = null + historySyncPending = true + historySyncPendingNonReconnecting ||= historySyncTimerNonReconnecting + historySyncTimerNonReconnecting = false + } + + function releaseHistorySync() { + historySyncHeld = false + flushPendingHistorySync() + } + function flushPendingHistorySync() { + if (historySyncHeld) return if (historyState.value.loading || failedHistoryRequest) return if (loadEarlierPending) { loadEarlierPending = false @@ -934,6 +956,10 @@ 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 } historySessionKey.value = key hasLoadedEarlier = false @@ -1074,6 +1100,15 @@ export function useChatHistory(options: UseChatHistoryOptions) { bootstrap, ) if (!isCurrentRequest()) return { ok: false, cancelled: true } + if (historySyncHeld) { + if (params.prepend) loadEarlierPending = true + else { + historySyncPending = true + historySyncPendingNonReconnecting ||= nonReconnecting + } + restoreSilentBackgroundState() + return { ok: false, cancelled: true } + } const msgs = data.messages const canonicalAvailable = data.canonicalAvailable if (canonicalAvailable === false) { @@ -1590,6 +1625,8 @@ export function useChatHistory(options: UseChatHistoryOptions) { retryHistory, markSessionMissing, scheduleHistorySync, + holdHistorySync, + releaseHistorySync, cancelAnchorStabilization, cancelActiveHistory, cleanup, diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 307edf5380..5f00a6c116 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -422,6 +422,62 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { 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 70de2b6f94..537813663d 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -459,9 +459,10 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) function beginBackgroundReceiptReplay(clientMessageId: string, holdHistory = false) { const normalizedClientId = String(clientMessageId || '').trim() if (!normalizedClientId) return + const isNewReceipt = !backgroundReceiptClientIds.has(normalizedClientId) pendingBackgroundReceiptClientIds.add(normalizedClientId) rememberBackgroundReceiptClient(normalizedClientId) - reconciledBackgroundReceiptClientIds.delete(normalizedClientId) + if (isNewReceipt) reconciledBackgroundReceiptClientIds.delete(normalizedClientId) if (holdHistory) holdBackgroundReceiptReconciliation() } @@ -580,6 +581,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) 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 diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 81ad0b9423..51ecfacb3a 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2405,7 +2405,7 @@ describe('useChatSend attachment payloads', () => { expect(messageActions.cancelEdit()).toBe(false) }) - it('exact-replays an unknown edited fork only while its transcript owner is unchanged', async () => { + it('keeps an edited receipt offscreen after its composer owner changes', async () => { const { sessionKey, messages, @@ -2443,15 +2443,15 @@ describe('useChatSend attachment payloads', () => { expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) inputText.value = 'draft typed while retrying' - // The live stream belongs to this request whose acceptance is unknown. - // An exact receipt replay may resolve it, but must not restart or clear it. + // 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', 'error']) + expect(messages.value.map(message => message.role)).toEqual(['user', 'error']) expect(inputText.value).toBe('draft typed while retrying') expect(stream.isStreaming.value).toBe(true) @@ -2684,6 +2684,89 @@ describe('useChatSend attachment payloads', () => { 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.each([ ['different text', 'new ordinary question'], ['the same text', 'later ordinary question'], diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 8047374a04..0b3ecbe703 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -191,6 +191,8 @@ interface SendAttempt { } 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 @@ -837,11 +839,14 @@ export function useChatSend(options: UseChatSendOptions) { ): boolean { const editOwner = attempt.messageEditTranscriptOwner if (editOwner) { - return !( + 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 @@ -871,6 +876,34 @@ export function useChatSend(options: UseChatSendOptions) { ) } + function sameComposerOwnershipSnapshot( + current: ComposerSnapshot, + owner: ComposerSnapshot, + ): boolean { + return ( + (owner.revision === null || current.revision === owner.revision) + && 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.messageEditGeneration === owner.messageEditGeneration + ) + } + + function rememberRecoveryComposerSnapshot(attempt: SendAttempt) { + if (attempt.recoveryComposerSnapshot) return + attempt.recoveryComposerSnapshot = captureComposerSnapshot() + } + watch([ () => options.sessionKey.value, () => options.inputText.value, @@ -3694,6 +3727,7 @@ export function useChatSend(options: UseChatSendOptions) { if (sendOpts.rememberRetryableAttempt) { sendOpts.rememberRetryableAttempt(attempt) } else { + rememberRecoveryComposerSnapshot(attempt) recoveredAttempt = attempt quarantineRecoveredAttemptIfUnrelated() } @@ -3701,6 +3735,7 @@ export function useChatSend(options: UseChatSendOptions) { // 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) { @@ -3988,6 +4023,7 @@ 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/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 3b852f7398..43810c5599 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -2396,6 +2396,8 @@ const { loadEarlierHistory, retryHistory: retryHistoryRequest, scheduleHistorySync, + holdHistorySync, + releaseHistorySync, cancelAnchorStabilization, cancelActiveHistory, markSessionMissing, @@ -2573,8 +2575,14 @@ const chatMessageActions = useChatMessageActions({ }, notifyMessagePending: () => pushToast(t('chat.toast.messageStillSaving'), { tone: 'info' }), notifyEditBlocked: () => pushToast(t('chat.pending.editWhileStreaming'), { tone: 'info' }), - onEditStarted: () => holdBackgroundReceiptReconciliation(), - onEditSettled: () => releaseBackgroundReceiptReconciliation(), + onEditStarted: () => { + holdBackgroundReceiptReconciliation() + holdHistorySync() + }, + onEditSettled: () => { + releaseBackgroundReceiptReconciliation() + releaseHistorySync() + }, }) const { copyMessage, From 2d29d722adf9ad55749c708dd976b834182111ee Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 20:58:47 +0800 Subject: [PATCH 10/24] fix(webui): close edit recovery ownership races --- .../composables/chat/useChatHistory.test.ts | 104 ++++++++++++ .../src/composables/chat/useChatHistory.ts | 33 ++-- .../chat/useChatMessageActions.test.ts | 34 ++++ .../composables/chat/useChatMessageActions.ts | 2 + .../chat/useChatRpcEventHandlers.test.ts | 79 +++++++++ .../chat/useChatRpcEventHandlers.ts | 9 ++ .../chat/useChatSend.attachments.test.ts | 151 ++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 28 +++- 8 files changed, 429 insertions(+), 11 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts index 14f88998f1..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 @@ -2444,6 +2519,35 @@ describe('useChatHistory canonical pagination', () => { } }) + 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 { diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.ts b/opensquilla-webui/src/composables/chat/useChatHistory.ts index 9453b04030..bca7e28755 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.ts @@ -730,6 +730,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { 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('') @@ -807,6 +808,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { } function holdHistorySync() { + if (!historySyncHeld) historySyncHoldSessionKey = options.sessionKey.value historySyncHeld = true if (!historySyncTimer) return clearTimeout(historySyncTimer) @@ -818,6 +820,14 @@ export function useChatHistory(options: UseChatHistoryOptions) { function releaseHistorySync() { historySyncHeld = false + if (historySyncHoldSessionKey !== options.sessionKey.value) { + historySyncHoldSessionKey = '' + historySyncPending = false + historySyncPendingNonReconnecting = false + loadEarlierPending = false + return + } + historySyncHoldSessionKey = '' flushPendingHistorySync() } @@ -960,6 +970,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { // session transition cancels its reads; never carry its apply hold into // the new session's bootstrap. historySyncHeld = false + historySyncHoldSessionKey = '' } historySessionKey.value = key hasLoadedEarlier = false @@ -1075,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()) { @@ -1100,15 +1121,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { bootstrap, ) if (!isCurrentRequest()) return { ok: false, cancelled: true } - if (historySyncHeld) { - if (params.prepend) loadEarlierPending = true - else { - historySyncPending = true - historySyncPendingNonReconnecting ||= nonReconnecting - } - restoreSilentBackgroundState() - return { ok: false, cancelled: true } - } + if (deferHeldHistoryRequest()) return { ok: false, cancelled: true } const msgs = data.messages const canonicalAvailable = data.canonicalAvailable if (canonicalAvailable === false) { @@ -1196,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) { @@ -1275,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. diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts index 54130e10b4..e22a1ab311 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.test.ts @@ -65,6 +65,7 @@ function makeOptions( opts?: { assistantBoundary?: boolean }, ) => string = text => text, aiGeneratedLabel?: () => string, + overrides: Partial = {}, ) { const sessionKey = ref('agent:main:webchat:A') const pendingForkBeforeMessageId = ref(null) @@ -84,6 +85,7 @@ function makeOptions( notifyMessagePending: vi.fn(), canDeliver: () => true, notifyDeliveryBlocked: vi.fn(), + ...overrides, } return { api: useChatMessageActions(options), options, sessionKey, pendingForkBeforeMessageId } } @@ -234,6 +236,38 @@ describe('useChatMessageActions branching edits', () => { 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' }, diff --git a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts index 890fc53a75..1fea35c461 100644 --- a/opensquilla-webui/src/composables/chat/useChatMessageActions.ts +++ b/opensquilla-webui/src/composables/chat/useChatMessageActions.ts @@ -82,9 +82,11 @@ export function useChatMessageActions(options: UseChatMessageActionsOptions) { // 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 { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 5f00a6c116..a527268b47 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -423,6 +423,85 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { } }) + 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('does not learn receipt task ownership from a stale subscription epoch', () => { const harness = createHarness() const deliver = (eventName: string, payload: Record) => { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index 537813663d..d7a36ef6d5 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -423,6 +423,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) const dirtyBackgroundReceiptClientIds = new Set() const reconciledBackgroundReceiptClientIds = new Set() let backgroundReceiptEditHeld = false + let backgroundReceiptHoldSessionKey = '' function rememberBackgroundReceiptClient(clientMessageId: string) { const normalizedClientId = String(clientMessageId || '').trim() @@ -439,6 +440,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) } function holdBackgroundReceiptReconciliation() { + if (!backgroundReceiptEditHeld) backgroundReceiptHoldSessionKey = sessionKey.value backgroundReceiptEditHeld = true } @@ -453,6 +455,12 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) function releaseBackgroundReceiptReconciliation() { backgroundReceiptEditHeld = false + if (backgroundReceiptHoldSessionKey !== sessionKey.value) { + dirtyBackgroundReceiptClientIds.clear() + backgroundReceiptHoldSessionKey = '' + return + } + backgroundReceiptHoldSessionKey = '' flushBackgroundReceiptReconciliationIfReady() } @@ -1567,6 +1575,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) dirtyBackgroundReceiptClientIds.clear() reconciledBackgroundReceiptClientIds.clear() backgroundReceiptEditHeld = false + backgroundReceiptHoldSessionKey = '' streamThinking.value = null clearGenerationTracking() turnReasoningLog.length = 0 diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 51ecfacb3a..55193ee3b4 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2767,6 +2767,157 @@ describe('useChatSend attachment payloads', () => { 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 second handoff write', async () => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const composerRevision = ref(0) + let finishReplayHandoff!: () => void + let handoffWrites = 0 + const baseWal = memoryHandoffWal() + const pendingInputWal: PendingInputWal = { + ...baseWal, + putHandoff: vi.fn(async (record) => { + handoffWrites += 1 + if (handoffWrites === 2) { + await new Promise(resolve => { + finishReplayHandoff = resolve + }) + } + await baseWal.putHandoff?.(record) + }), + } + 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.putHandoff).toHaveBeenCalledTimes(2)) + 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([ ['different text', 'new ordinary question'], ['the same text', 'later ordinary question'], diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 0b3ecbe703..0c5d436ff5 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -223,6 +223,7 @@ interface ExplicitSendPayload { interface ComposerSnapshot { revision: number | null + ownershipEpoch: number inputText: string promptAnnotationIds: string[] documentContext: TurnDocumentContext | null @@ -659,6 +660,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 @@ -814,6 +816,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( @@ -882,6 +885,7 @@ export function useChatSend(options: UseChatSendOptions) { ): 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) @@ -916,7 +920,14 @@ export function useChatSend(options: UseChatSendOptions) { () => options.messageEditActive?.value, () => options.initialCollaborationMode.value, () => options.initialRoutingMode.value, - ], quarantineRecoveredAttemptIfUnrelated, { flush: 'sync', deep: true }) + () => options.composerRevision?.value, + () => JSON.stringify(normalizeDocumentContext( + options.currentDocumentContext?.(options.sessionKey.value), + )), + ], () => { + composerOwnershipEpoch += 1 + quarantineRecoveredAttemptIfUnrelated() + }, { flush: 'sync', deep: true }) function messageEditOwnerMatchesSnapshot( snapshot: ComposerSnapshot, @@ -3157,7 +3168,7 @@ export function useChatSend(options: UseChatSendOptions) { : false if (sendOpts.cancelIfComposerChanged && composerChanged) return 'not_sent' if (composerChanged) preserveComposer = true - const backgroundReceiptReplay = sendOpts.backgroundReceiptReplay === true + let backgroundReceiptReplay = sendOpts.backgroundReceiptReplay === true || Boolean( sendOpts.idempotentReplay && preserveComposer @@ -3369,6 +3380,18 @@ export function useChatSend(options: UseChatSendOptions) { } 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', { @@ -3476,6 +3499,7 @@ export function useChatSend(options: UseChatSendOptions) { ) attempt.acceptanceRequest = { request: acceptanceRequest } attempt.acceptanceInFlight = true + rememberRecoveryComposerSnapshot(attempt) if (backgroundReceiptReplay) { options.beginBackgroundReceiptReplay?.( attempt.clientMessageId, From beb6ded922e0db299705b81d408d2bb8bb74d86e Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 21:16:30 +0800 Subject: [PATCH 11/24] fix(webui): settle isolated receipt lifecycles --- .../chat/useChatRpcEventHandlers.test.ts | 67 ++++++++- .../chat/useChatRpcEventHandlers.ts | 50 +++++-- .../chat/useChatSend.attachments.test.ts | 138 ++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 44 +++++- 4 files changed, 284 insertions(+), 15 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index a527268b47..355243bc6d 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -2,6 +2,7 @@ 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, @@ -38,6 +39,7 @@ function createHarness(options: { 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') @@ -79,6 +81,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() @@ -121,7 +124,7 @@ function createHarness(options: { appendEnsembleProgress: vi.fn(), markEnsembleHandoff, flushPendingRouterDecision: vi.fn(), - clearPendingRouterDecision: vi.fn(), + clearPendingRouterDecision, handleRouterControlReplay: vi.fn(), showCompactionToast, getCompactionPlacement: options.getCompactionPlacement, @@ -138,6 +141,7 @@ function createHarness(options: { handleSessionConnectionState, loadCurrentSessionUsage, refreshRunModePreference, + taskOwnership: options.taskOwnership, }))! const api = { ...rawApi, @@ -163,6 +167,7 @@ function createHarness(options: { markEnsembleHandoff, bindRouterDecisionToModelCall, queueRouterDecision, + clearPendingRouterDecision, schedulePendingDrainAfterTerminal, scheduleHistorySync, showCompactionToast, @@ -396,7 +401,7 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { expect(harness.stream.startStreaming).not.toHaveBeenCalled() expect(harness.stream.appendDelta).not.toHaveBeenCalled() expect(harness.stream.endStreaming).not.toHaveBeenCalled() - expect(harness.applySessionRunState).toHaveBeenCalledOnce() + expect(harness.applySessionRunState).toHaveBeenCalledTimes(2) expect(harness.applySessionRunState).toHaveBeenCalledWith(expect.objectContaining({ run_status: 'running', active_task: expect.objectContaining({ task_id: 'task-successor' }), @@ -502,6 +507,64 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { } }) + 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(true) + + 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 learn receipt task ownership from a stale subscription epoch', () => { const harness = createHarness() const deliver = (eventName: string, payload: Record) => { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index d7a36ef6d5..38bc7ce4a3 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -563,12 +563,12 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) 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 + 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' }) @@ -583,6 +583,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) delete continuation.lastTask delete continuation.status handleRpcSessionsChanged(continuation) + return true } function suppressBackgroundReceiptEvent( @@ -594,6 +595,12 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) const identity = matchingBackgroundReceiptIdentity(payload) if (!identity) return false const { clientMessageId: owner, taskId } = identity + const terminalEvent = eventKind === 'sessions-changed' + ? sessionChangeIsTerminal(payload) + : isTerminalEvent(eventKind) + const terminalWasAlreadySeen = [...backgroundReceiptTasks.values()].some(task => ( + task.clientMessageId === owner && task.terminalSeen + )) // 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. @@ -602,12 +609,9 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) options.taskOwnership?.noteQueued({ ...payload, status: 'queued' }) } else if (eventKind === 'task-running') { options.taskOwnership?.noteRunning({ ...payload, status: 'running' }) - } else if ( - eventKind === 'sessions-changed' - ? sessionChangeIsTerminal(payload) - : isTerminalEvent(eventKind) - ) { + } else if (terminalEvent) { options.taskOwnership?.noteTerminal(taskId) + markTaskSettled(payload) rememberBackgroundReceiptTask(owner, taskId, true) if (!reconciledBackgroundReceiptClientIds.has(owner)) { dirtyBackgroundReceiptClientIds.add(owner) @@ -618,8 +622,36 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) // (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. - if (eventKind === 'sessions-changed') { - applyBackgroundReceiptContinuation(payload, taskId) + const hasContinuation = eventKind === 'sessions-changed' + && applyBackgroundReceiptContinuation(payload, taskId) + if ( + terminalEvent + && !terminalWasAlreadySeen + && !hasContinuation + && activeTaskGroups.value.size === 0 + && !options.taskOwnership?.hasAuthoritativeWork.value + ) { + const terminalTask = terminalSessionChangeTask(payload) + const rawStatus = String( + terminalTask?.status || payload.status || payload.task_status || '', + ).trim().toLowerCase() + const failed = rawStatus === 'failed' || eventKind === 'task-failed' || eventKind === 'turn-failed' + const interrupted = ['cancelled', 'abandoned', 'interrupted'].includes(rawStatus) + if (!stream.isStreaming.value) { + clearLiveThinking() + options.clearPendingRouterDecision() + } + options.applySessionRunState({ + run_status: failed ? 'failed' : interrupted ? 'cancelled' : 'idle', + last_task: { + ...(terminalTask || payload), + task_id: taskId, + status: rawStatus || (failed ? 'failed' : 'succeeded'), + }, + }) + if (pendingQueue.value.length > 0 && !interrupted) { + options.schedulePendingDrainAfterTerminal() + } } return true } diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 55193ee3b4..1fafb08884 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2918,6 +2918,144 @@ describe('useChatSend attachment payloads', () => { expect(messageActions.cancelEdit()).toBe(true) }) + it.each(['Escape', 'session switch'] as const)( + 'retains an unknown fork handoff when %s invalidates its replay during the second write', + async (invalidation) => { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + let finishReplayHandoff!: () => void + let handoffWrites = 0 + const baseWal = memoryHandoffWal() + const pendingInputWal: PendingInputWal = { + ...baseWal, + putHandoff: vi.fn(async (record) => { + handoffWrites += 1 + if (handoffWrites === 2) { + await new Promise(resolve => { + finishReplayHandoff = resolve + }) + } + await baseWal.putHandoff?.(record) + }), + } + 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.putHandoff).toHaveBeenCalledTimes(2)) + + 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 } = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + taskOwnership, + adoptResponseSession, + 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(inputText.value).toBe('newer edit owner') + expect(pendingForkBeforeMessageId.value).toBe('msg-original') + expect(messageActions.cancelEdit()).toBe(true) + }, + ) + it.each([ ['different text', 'new ordinary question'], ['the same text', 'later ordinary question'], diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 0c5d436ff5..c036e71e1d 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -1527,6 +1527,7 @@ export function useChatSend(options: UseChatSendOptions) { async function persistResponseHandoff( attempt: SendAttempt, requirePrepared = false, + preserveExistingOnFailure = false, ): Promise { const wal = options.pendingInputWal if (!wal) return null @@ -1590,7 +1591,7 @@ export function useChatSend(options: UseChatSendOptions) { record.walRevision, null, ).catch(() => {}) - } else { + } else if (!preserveExistingOnFailure) { await wal.deleteHandoff?.(record.ownerRequestId).catch(() => {}) } return null @@ -1742,6 +1743,16 @@ export function useChatSend(options: UseChatSendOptions) { await options.pendingInputWal.putHandoff(accepted).catch(() => {}) } + async function finalizeBackgroundResponseHandoff( + gate: ResponseHandoffGate, + acceptedSessionKey: string, + ): Promise { + await markResponseHandoffAccepted(gate, acceptedSessionKey) + if (gate.durableRecord && await deleteResponseHandoff(gate.durableRecord)) { + gate.durableRecord = null + } + } + async function markResponseHandoffFailed( gate: ResponseHandoffGate, error: unknown, @@ -3261,11 +3272,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) { @@ -3374,6 +3390,7 @@ export function useChatSend(options: UseChatSendOptions) { durableHandoffRecord = await persistResponseHandoff( attempt, sendOpts.requirePreparedHandoff, + preservesExistingReplayHandoff, ) if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { return rejectBeforeDispatch() @@ -3523,13 +3540,24 @@ export function useChatSend(options: UseChatSendOptions) { responseOwnsVisibleTranscript = false } if (!responseOwnsVisibleTranscript) { + const acceptedSessionKey = res?.sessionKey || requestSessionKey const terminalStatus = terminalResponseStatus(res) - const accepted = noteAcceptedTask(res, requestSessionKey) + 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) + } options.trackBackgroundReceiptTask?.( attempt.clientMessageId, - accepted.taskId, + taskId, Boolean(terminalStatus), ) + if (responseHandoff) { + responseHandoff.acceptedTaskId = taskId + responseHandoff.terminalResponse = Boolean(terminalStatus) + await finalizeBackgroundResponseHandoff(responseHandoff, acceptedSessionKey) + } if (recoveredAttempt?.clientRequestId === attempt.clientRequestId) { recoveredAttempt = null } @@ -3733,7 +3761,15 @@ export function useChatSend(options: UseChatSendOptions) { if (acceptedError) consumeAcceptedSessionIntent(attempt) if (acceptedError && !acceptedResponseOwnsVisibleTranscript) { attempt.acceptanceResolved = true + attempt.acceptedSessionKey = acceptedError.sessionKey || requestSessionKey options.trackBackgroundReceiptTask?.(attempt.clientMessageId, '', true) + if (responseHandoff) { + responseHandoff.terminalResponse = acceptedError.terminalWithoutTask + await finalizeBackgroundResponseHandoff( + responseHandoff, + attempt.acceptedSessionKey, + ) + } if (!wasStreaming && freshSendToken && activeFreshSendToken === freshSendToken) { activeFreshSendToken = null options.activeStreamTaskId.value = '' From c9ee32da67aa6693fa91446fb9cfc7c53751aef3 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 22:38:04 +0800 Subject: [PATCH 12/24] Make recovered receipt settlement crash-safe --- .../chat/useChatRpcEventHandlers.test.ts | 91 +++++ .../chat/useChatRpcEventHandlers.ts | 106 +++-- .../chat/useChatSend.attachments.test.ts | 386 +++++++++++++++++- .../src/composables/chat/useChatSend.ts | 159 ++++++-- .../src/utils/chat/pendingInputWal.ts | 6 + opensquilla-webui/src/views/ChatView.vue | 2 +- 6 files changed, 665 insertions(+), 85 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 355243bc6d..ff21c83a10 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -536,6 +536,12 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { }) expect(taskOwnership.hasAuthoritativeWork.value).toBe(true) + harness.api.trackBackgroundReceiptTask( + 'client-old-receipt', + 'task-old-receipt', + 'succeeded', + ) + deliver('task.succeeded', { session_key: 'agent:main:test', task_id: 'task-old-receipt', @@ -565,6 +571,91 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { } }) + 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: {}, + }) + + 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: {}, + }) + + 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('does not learn receipt task ownership from a stale subscription epoch', () => { const harness = createHarness() const deliver = (eventName: string, payload: Record) => { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index 38bc7ce4a3..cf4f9275ae 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -422,6 +422,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) const backgroundReceiptTasks = new Map() const dirtyBackgroundReceiptClientIds = new Set() const reconciledBackgroundReceiptClientIds = new Set() + const settledBackgroundReceiptClientIds = new Set() let backgroundReceiptEditHeld = false let backgroundReceiptHoldSessionKey = '' @@ -434,6 +435,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) backgroundReceiptClientIds.delete(oldestClientId) dirtyBackgroundReceiptClientIds.delete(oldestClientId) reconciledBackgroundReceiptClientIds.delete(oldestClientId) + settledBackgroundReceiptClientIds.delete(oldestClientId) } } backgroundReceiptClientIds.add(normalizedClientId) @@ -497,14 +499,63 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) function trackBackgroundReceiptTask( clientMessageId: string, taskId: string, - terminal = false, + terminal: boolean | string = false, ) { const normalizedClientId = String(clientMessageId || '').trim() + const normalizedTaskId = String(taskId || '').trim() + const terminalStatus = typeof terminal === 'string' + ? terminal.trim().toLowerCase() + : terminal ? 'succeeded' : '' rememberBackgroundReceiptClient(normalizedClientId) - rememberBackgroundReceiptTask(normalizedClientId, taskId, terminal) - if (terminal && !reconciledBackgroundReceiptClientIds.has(normalizedClientId)) { - dirtyBackgroundReceiptClientIds.add(normalizedClientId) - flushBackgroundReceiptReconciliationIfReady() + rememberBackgroundReceiptTask(normalizedClientId, normalizedTaskId, Boolean(terminalStatus)) + if (terminalStatus) { + if (normalizedTaskId) options.taskOwnership?.noteTerminal(normalizedTaskId) + if (!reconciledBackgroundReceiptClientIds.has(normalizedClientId)) { + dirtyBackgroundReceiptClientIds.add(normalizedClientId) + flushBackgroundReceiptReconciliationIfReady() + } + settleBackgroundReceiptTerminal( + normalizedClientId, + normalizedTaskId, + terminalStatus, + {}, + true, + ) + } + } + + 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 failed = ['failed', 'timeout', 'abandoned'].includes(rawStatus) + const interrupted = ['cancelled', 'interrupted'].includes(rawStatus) + clearLiveThinking() + options.clearPendingRouterDecision() + options.applySessionRunState({ + run_status: failed ? 'failed' : interrupted ? 'cancelled' : 'idle', + last_task: { + ...terminalTask, + ...(taskId ? { task_id: taskId } : {}), + status: rawStatus || (failed ? 'failed' : 'succeeded'), + }, + }) + if (pendingQueue.value.length > 0 && !interrupted) { + options.schedulePendingDrainAfterTerminal() } } @@ -598,9 +649,6 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) const terminalEvent = eventKind === 'sessions-changed' ? sessionChangeIsTerminal(payload) : isTerminalEvent(eventKind) - const terminalWasAlreadySeen = [...backgroundReceiptTasks.values()].some(task => ( - task.clientMessageId === owner && task.terminalSeen - )) // 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. @@ -624,34 +672,25 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) // the receipt's history sync to replace the newer Edit transcript. const hasContinuation = eventKind === 'sessions-changed' && applyBackgroundReceiptContinuation(payload, taskId) - if ( - terminalEvent - && !terminalWasAlreadySeen - && !hasContinuation - && activeTaskGroups.value.size === 0 - && !options.taskOwnership?.hasAuthoritativeWork.value - ) { + if (terminalEvent) { const terminalTask = terminalSessionChangeTask(payload) const rawStatus = String( - terminalTask?.status || payload.status || payload.task_status || '', + terminalTask?.status + || payload.status + || payload.task_status + || payload.run_status + || payload.runStatus + || '', ).trim().toLowerCase() - const failed = rawStatus === 'failed' || eventKind === 'task-failed' || eventKind === 'turn-failed' - const interrupted = ['cancelled', 'abandoned', 'interrupted'].includes(rawStatus) - if (!stream.isStreaming.value) { - clearLiveThinking() - options.clearPendingRouterDecision() - } - options.applySessionRunState({ - run_status: failed ? 'failed' : interrupted ? 'cancelled' : 'idle', - last_task: { - ...(terminalTask || payload), - task_id: taskId, - status: rawStatus || (failed ? 'failed' : 'succeeded'), - }, - }) - if (pendingQueue.value.length > 0 && !interrupted) { - options.schedulePendingDrainAfterTerminal() - } + settleBackgroundReceiptTerminal( + owner, + taskId, + rawStatus + || (eventKind === 'sessions-changed' ? '' : eventTaskTerminalStatus(eventKind)) + || (eventKind === 'turn-failed' ? 'failed' : 'succeeded'), + terminalTask || payload, + !hasContinuation, + ) } return true } @@ -1606,6 +1645,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) backgroundReceiptTasks.clear() dirtyBackgroundReceiptClientIds.clear() reconciledBackgroundReceiptClientIds.clear() + settledBackgroundReceiptClientIds.clear() backgroundReceiptEditHeld = false backgroundReceiptHoldSessionKey = '' streamThinking.value = null diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 1fafb08884..5fa40114e4 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2837,7 +2837,7 @@ describe('useChatSend attachment payloads', () => { expect(messageActions.cancelEdit()).toBe(true) }) - it('rechecks exact-replay ownership after the second handoff write', async () => { + it('rechecks exact-replay ownership after the durable handoff lookup', async () => { const { sessionKey, messages, @@ -2847,18 +2847,18 @@ describe('useChatSend attachment payloads', () => { } = makeEditedMessageState('edited question') const composerRevision = ref(0) let finishReplayHandoff!: () => void - let handoffWrites = 0 + let handoffLookups = 0 const baseWal = memoryHandoffWal() const pendingInputWal: PendingInputWal = { ...baseWal, - putHandoff: vi.fn(async (record) => { - handoffWrites += 1 - if (handoffWrites === 2) { + listHandoffs: vi.fn(async (requestSessionKey) => { + handoffLookups += 1 + if (handoffLookups === 1) { await new Promise(resolve => { finishReplayHandoff = resolve }) } - await baseWal.putHandoff?.(record) + return baseWal.listHandoffs?.(requestSessionKey) || [] }), } const beginBackgroundReceiptReplay = vi.fn() @@ -2889,7 +2889,7 @@ describe('useChatSend attachment payloads', () => { await api.onSend() const originalParams = rpc.call.mock.calls[0]?.[1] const replay = api.onSend() - await vi.waitFor(() => expect(pendingInputWal.putHandoff).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(pendingInputWal.listHandoffs).toHaveBeenCalledOnce()) expect(rpc.call).toHaveBeenCalledOnce() inputText.value = 'edited question' @@ -2919,7 +2919,7 @@ describe('useChatSend attachment payloads', () => { }) it.each(['Escape', 'session switch'] as const)( - 'retains an unknown fork handoff when %s invalidates its replay during the second write', + 'retains an unknown fork handoff when %s invalidates its durable lookup', async (invalidation) => { const { sessionKey, @@ -2929,18 +2929,18 @@ describe('useChatSend attachment payloads', () => { messageActions, } = makeEditedMessageState('edited question') let finishReplayHandoff!: () => void - let handoffWrites = 0 + let handoffLookups = 0 const baseWal = memoryHandoffWal() const pendingInputWal: PendingInputWal = { ...baseWal, - putHandoff: vi.fn(async (record) => { - handoffWrites += 1 - if (handoffWrites === 2) { + listHandoffs: vi.fn(async (requestSessionKey) => { + handoffLookups += 1 + if (handoffLookups === 1) { await new Promise(resolve => { finishReplayHandoff = resolve }) } - await baseWal.putHandoff?.(record) + return baseWal.listHandoffs?.(requestSessionKey) || [] }), } const rpc = { @@ -2968,7 +2968,7 @@ describe('useChatSend attachment payloads', () => { await api.onSend() const ownerRequestId = String(rpc.call.mock.calls[0]?.[1]?.clientRequestId) const replay = api.onSend() - await vi.waitFor(() => expect(pendingInputWal.putHandoff).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(pendingInputWal.listHandoffs).toHaveBeenCalledOnce()) if (invalidation === 'Escape') { expect(messageActions.cancelEdit()).toBe(false) @@ -3023,7 +3023,7 @@ describe('useChatSend attachment payloads', () => { task_id: 'task-child-receipt', })), } - const { api } = makeOptions({ + const { api, options } = makeOptions({ rpc, sessionKey, messages, @@ -3032,6 +3032,7 @@ describe('useChatSend attachment payloads', () => { pendingInputWal, taskOwnership, adoptResponseSession, + hasPendingQueueWork: () => true, messageEditGeneration: messageActions.editGeneration, messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, @@ -3050,12 +3051,205 @@ describe('useChatSend attachment payloads', () => { 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('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'], @@ -3117,6 +3311,49 @@ describe('useChatSend attachment payloads', () => { }, ) + 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, + ) + expect(inputText.value).toBe('newer question') + }, + ) + it('does not start async queue persistence for a fork edit while work is active', async () => { const { sessionKey, @@ -3168,15 +3405,15 @@ describe('useChatSend attachment payloads', () => { } = makeEditedMessageState('edited question') let finishHandoff!: () => void - let handoffWrites = 0 + const baseWal = memoryHandoffWal() const pendingInputWal: PendingInputWal = { - ...memoryHandoffWal(), - putHandoff: vi.fn(() => { - handoffWrites += 1 - if (handoffWrites > 1) return Promise.resolve() - return new Promise(resolve => { + ...baseWal, + prepareHandoff: vi.fn(async (record) => { + const prepared = await baseWal.prepareHandoff!(record) + await new Promise(resolve => { finishHandoff = resolve }) + return prepared }), } const acceptanceStopPending = ref(false) @@ -3193,7 +3430,7 @@ describe('useChatSend attachment payloads', () => { }) const send = api.onSend() - await vi.waitFor(() => expect(pendingInputWal.putHandoff).toHaveBeenCalled()) + await vi.waitFor(() => expect(pendingInputWal.prepareHandoff).toHaveBeenCalled()) acceptanceStopPending.value = true finishHandoff() await send @@ -7394,6 +7631,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 c036e71e1d..74f69ecc2d 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -635,7 +635,7 @@ export interface UseChatSendOptions { trackBackgroundReceiptTask?: ( clientMessageId: string, taskId: string, - terminal?: boolean, + terminal?: boolean | string, ) => void /** Release the pre-response event quarantine for an older receipt replay. */ finishBackgroundReceiptReplay?: (clientMessageId: string) => void @@ -1335,14 +1335,17 @@ export function useChatSend(options: UseChatSendOptions) { attempt: SendAttempt, response: TurnSendResponse, ): Promise { + const acceptedSessionKey = response.sessionKey || attempt.requestSessionKey let responseOwnsVisibleTranscript = !recoveredAttemptHasUnrelatedComposer( attempt, captureComposerSnapshot(), - ) && validateAttemptMessageEditTranscript(attempt) + ) + && acceptedSessionKey === attempt.requestSessionKey + && validateAttemptMessageEditTranscript(attempt) acknowledgeAttemptPromptAnnotations(attempt, response, responseOwnsVisibleTranscript) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(response) - attempt.acceptedSessionKey = response.sessionKey || attempt.requestSessionKey + attempt.acceptedSessionKey = acceptedSessionKey if ( responseOwnsVisibleTranscript && attempt.messageEditTranscriptOwner @@ -1362,7 +1365,9 @@ 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 && responseOwnsVisibleTranscript) { consumeAcceptedSessionIntent(attempt) @@ -1370,11 +1375,16 @@ export function useChatSend(options: UseChatSendOptions) { options.scheduleHistorySync() } else if (isCurrentRequest) { consumeAcceptedSessionIntent(attempt) - options.trackBackgroundReceiptTask?.( - attempt.clientMessageId, - accepted.taskId, - Boolean(terminalStatus), - ) + if (acceptedSessionKey === attempt.requestSessionKey) { + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + accepted.taskId, + terminalStatus, + ) + } + } + if (!responseOwnsVisibleTranscript && attempt.forkBeforeMessageId) { + await finalizeAttemptBackgroundResponseHandoff(attempt, acceptedSessionKey) } if (!attempt.stopRequested) { @@ -1428,15 +1438,26 @@ export function useChatSend(options: UseChatSendOptions) { } 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)) return + continue + } + if (rpcError?.accepted === false) { 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, @@ -1531,10 +1552,23 @@ export function useChatSend(options: UseChatSendOptions) { ): 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 } @@ -1553,7 +1587,7 @@ export function useChatSend(options: UseChatSendOptions) { ...(attempt.replayCoordinationKey ? { replayCoordinationKey: attempt.replayCoordinationKey } : {}), - ...(requirePrepared + ...(useOwnedWal ? { walOwnerId: attempt.handoffWalOwnerId!, walRevision: attempt.handoffWalRevision!, @@ -1564,12 +1598,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 @@ -1581,7 +1615,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. @@ -1721,6 +1755,7 @@ export function useChatSend(options: UseChatSendOptions) { ...current, state: 'accepted', acceptedSessionKey, + ...(gate.backgroundOnly ? { backgroundOnly: true } : {}), ...(current.walRevision ? { walRevision: current.walRevision + 1 } : {}), updatedAt: Date.now(), } @@ -1747,12 +1782,56 @@ export function useChatSend(options: UseChatSendOptions) { gate: ResponseHandoffGate, acceptedSessionKey: string, ): Promise { + gate.targetSessionKey = acceptedSessionKey + gate.backgroundOnly = true await markResponseHandoffAccepted(gate, acceptedSessionKey) if (gate.durableRecord && await deleteResponseHandoff(gate.durableRecord)) { gate.durableRecord = null } } + function releaseBackgroundResponseHandoffParent(gate: ResponseHandoffGate): void { + if ( + !gate.backgroundOnly + || 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 + const records = await wal.listHandoffs(attempt.requestSessionKey).catch(() => []) + const record = records.find(candidate => ( + candidate.ownerRequestId === attempt.clientRequestId + && candidate.clientRequestId === attempt.clientRequestId + && candidate.clientMessageId === attempt.clientMessageId + )) + if (!record) return + const gate: ResponseHandoffGate = { + requestSessionKey: attempt.requestSessionKey, + ownerRequestId: attempt.clientRequestId, + targetSessionKey: acceptedSessionKey, + stoppedByUser: attempt.stopRequested === true, + acceptedTaskId: attempt.acceptedTaskId || '', + terminalResponse: false, + authoritativeIdle: false, + backgroundOnly: true, + durableRecord: record, + } + await finalizeBackgroundResponseHandoff(gate, acceptedSessionKey) + releaseBackgroundResponseHandoffParent(gate) + } + async function markResponseHandoffFailed( gate: ResponseHandoffGate, error: unknown, @@ -1893,6 +1972,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 @@ -2025,7 +2105,11 @@ export function useChatSend(options: UseChatSendOptions) { continue } if (record.state === 'accepted' && record.acceptedSessionKey) { - await finalizeRecoveredHandoff(record, record.acceptedSessionKey) + if (record.backgroundOnly) { + await deleteResponseHandoff(record) + } else { + await finalizeRecoveredHandoff(record, record.acceptedSessionKey) + } continue } let replayRecord = record @@ -3392,7 +3476,10 @@ export function useChatSend(options: UseChatSendOptions) { sendOpts.requirePreparedHandoff, preservesExistingReplayHandoff, ) - if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { + if ( + (sendOpts.requirePreparedHandoff || preservesExistingReplayHandoff) + && !durableHandoffRecord + ) { return rejectBeforeDispatch() } if (!preDispatchAllowed()) return rejectBeforeDispatch() @@ -3548,11 +3635,13 @@ export function useChatSend(options: UseChatSendOptions) { if (acceptedSessionKey === requestSessionKey) { noteAcceptedTask(res, requestSessionKey) } - options.trackBackgroundReceiptTask?.( - attempt.clientMessageId, - taskId, - Boolean(terminalStatus), - ) + if (acceptedSessionKey === requestSessionKey) { + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + taskId, + terminalStatus, + ) + } if (responseHandoff) { responseHandoff.acceptedTaskId = taskId responseHandoff.terminalResponse = Boolean(terminalStatus) @@ -3762,7 +3851,13 @@ export function useChatSend(options: UseChatSendOptions) { if (acceptedError && !acceptedResponseOwnsVisibleTranscript) { attempt.acceptanceResolved = true attempt.acceptedSessionKey = acceptedError.sessionKey || requestSessionKey - options.trackBackgroundReceiptTask?.(attempt.clientMessageId, '', true) + if (attempt.acceptedSessionKey === requestSessionKey) { + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + '', + acceptedError.terminalWithoutTask ? 'failed' : false, + ) + } if (responseHandoff) { responseHandoff.terminalResponse = acceptedError.terminalWithoutTask await finalizeBackgroundResponseHandoff( @@ -3871,7 +3966,13 @@ export function useChatSend(options: UseChatSendOptions) { } if (!attemptTranscriptIdentityStillOwned(attempt)) { attempt.acceptanceResolved = true - options.trackBackgroundReceiptTask?.(attempt.clientMessageId, '', true) + if (acceptedSessionKey === requestSessionKey) { + options.trackBackgroundReceiptTask?.( + attempt.clientMessageId, + '', + acceptedError.terminalWithoutTask ? 'failed' : false, + ) + } if (!wasStreaming && freshSendToken && activeFreshSendToken === freshSendToken) { activeFreshSendToken = null options.activeStreamTaskId.value = '' diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index 0e565aa98d..2849dcf323 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. */ @@ -203,6 +205,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 || ( diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 43810c5599..c3e39cca74 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1701,7 +1701,7 @@ let beginBackgroundReceiptReplay = (_clientMessageId: string, _holdHistory = fal let trackBackgroundReceiptTask = ( _clientMessageId: string, _taskId: string, - _terminal = false, + _terminal: boolean | string = false, ) => {} let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} let holdBackgroundReceiptReconciliation = () => {} From 956225ab6e83de11f823cec128f52f3554d7a7ef Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 23:03:26 +0800 Subject: [PATCH 13/24] Complete background receipt handoff recovery --- .../chat/useChatPendingQueue.test.ts | 57 +++ .../composables/chat/useChatPendingQueue.ts | 11 +- .../chat/useChatRpcEventHandlers.test.ts | 46 +- .../chat/useChatRpcEventHandlers.ts | 7 +- .../chat/useChatSend.attachments.test.ts | 395 +++++++++++++++++- .../src/composables/chat/useChatSend.ts | 204 +++++++-- 6 files changed, 678 insertions(+), 42 deletions(-) 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 ff21c83a10..f659aa4865 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -33,6 +33,7 @@ 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 @@ -116,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, @@ -656,6 +657,49 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { }, ) + 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: {}, + }) + + 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) => { diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index cf4f9275ae..f4bf898b70 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -542,8 +542,9 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) || activeTaskGroups.value.size > 0 || options.taskOwnership?.hasAuthoritativeWork.value ) return - const failed = ['failed', 'timeout', 'abandoned'].includes(rawStatus) - const interrupted = ['cancelled', 'interrupted'].includes(rawStatus) + const normalizedStatus = options.normalizeRunStatus(rawStatus) + const failed = ['failed', 'timeout', 'abandoned'].includes(normalizedStatus) + const interrupted = ['cancelled', 'interrupted'].includes(normalizedStatus) clearLiveThinking() options.clearPendingRouterDecision() options.applySessionRunState({ @@ -551,7 +552,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) last_task: { ...terminalTask, ...(taskId ? { task_id: taskId } : {}), - status: rawStatus || (failed ? 'failed' : 'succeeded'), + status: normalizedStatus || (failed ? 'failed' : 'succeeded'), }, }) if (pendingQueue.value.length > 0 && !interrupted) { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 5fa40114e4..3215214628 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -205,6 +205,7 @@ function makeOptions(overrides: SendHarnessOverrides = {}) { stream, normalizeElevatedMode: mode => mode, adoptResponseSession: vi.fn(), + recoverPendingQueueHandoff: vi.fn(async () => true), scheduleHistorySync, schedulePendingDrainAfterTerminal: vi.fn(), flushDeferredPendingDrain: vi.fn(), @@ -1256,7 +1257,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 })), @@ -1400,7 +1401,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, @@ -3129,6 +3130,396 @@ describe('useChatSend attachment payloads', () => { expect(await pendingInputWal.listHandoffs?.()).toEqual([]) }) + it('replays a background-only submitting WAL without adopting its child', 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([]) + }) + + it('releases a crashed accepted background-only owner back to the parent drain', async () => { + const parent = 'agent:main:webchat:accepted-background-parent' + const child = 'agent:main:webchat:accepted-background-child' + const ownerRequestId = 'accepted-background-request' + const pendingInputWal = memoryHandoffWal() + await pendingInputWal.prepareHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'accepted-background-message', + composerText: 'already accepted offscreen', + recoveryAttachments: [], + params: { + sessionKey: parent, + clientRequestId: ownerRequestId, + clientMessageId: 'accepted-background-message', + message: 'already accepted offscreen', + forkBeforeMessageId: 'fork-anchor', + }, + backgroundOnly: true, + acceptedSessionKey: child, + walOwnerId: 'accepted-background-owner', + walRevision: 4, + state: 'accepted', + createdAt: 1, + updatedAt: 2, + }) + const adoptResponseSession = vi.fn() + const recoverPendingQueueHandoff = vi.fn(async () => true) + const harness = makeOptions({ + sessionKey: ref(parent), + pendingInputWal, + adoptResponseSession, + recoverPendingQueueHandoff, + hasPendingQueueWork: () => true, + }) + + await harness.api.recoverResponseHandoffs() + + expect(harness.rpc.call).not.toHaveBeenCalled() + expect(adoptResponseSession).not.toHaveBeenCalled() + expect(recoverPendingQueueHandoff).toHaveBeenCalledWith(parent, parent, ownerRequestId) + expect(harness.options.flushDeferredPendingDrain).toHaveBeenCalledOnce() + expect(harness.options.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + }) + + it('keeps the recovery worker until the background-only accepted CAS is durable', async () => { + vi.useFakeTimers() + try { + const { + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + messageActions, + } = makeEditedMessageState('edited question') + const baseWal = memoryHandoffWal() + let failAcceptedTransition = true + 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 } + } + 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: 'submitting', 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('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, diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 74f69ecc2d..248aafead2 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -284,6 +284,7 @@ interface ResponseHandoffGate { terminalResponse: boolean authoritativeIdle: boolean backgroundOnly: boolean + backgroundFinalized: boolean durableRecord: ResponseHandoffWalRecord | null } @@ -570,7 +571,7 @@ export interface UseChatSendOptions { sourceSessionKey: string, targetSessionKey: string, ownerRequestId: string, - ) => Promise + ) => Promise failPendingQueueHandoff?: (ownerRequestId: string) => Promise | void scheduleHistorySync: () => void schedulePendingDrainAfterTerminal: () => void @@ -1383,8 +1384,13 @@ export function useChatSend(options: UseChatSendOptions) { ) } } - if (!responseOwnsVisibleTranscript && attempt.forkBeforeMessageId) { - await finalizeAttemptBackgroundResponseHandoff(attempt, acceptedSessionKey) + if ( + !responseOwnsVisibleTranscript + && attempt.forkBeforeMessageId + && !await finalizeAttemptBackgroundResponseHandoff(attempt, acceptedSessionKey) + ) { + attempt.acceptanceResolved = false + return false } if (!attempt.stopRequested) { @@ -1536,6 +1542,7 @@ export function useChatSend(options: UseChatSendOptions) { terminalResponse: false, authoritativeIdle: false, backgroundOnly: false, + backgroundFinalized: false, durableRecord, } activeResponseHandoff = gate @@ -1745,12 +1752,53 @@ 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) + ) return true const accepted: ResponseHandoffWalRecord = { ...current, state: 'accepted', @@ -1770,29 +1818,58 @@ 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 finalizeBackgroundResponseHandoff( gate: ResponseHandoffGate, acceptedSessionKey: string, - ): Promise { + ): Promise { gate.targetSessionKey = acceptedSessionKey gate.backgroundOnly = true - await markResponseHandoffAccepted(gate, acceptedSessionKey) + 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 + gate.backgroundFinalized = true if (gate.durableRecord && await deleteResponseHandoff(gate.durableRecord)) { gate.durableRecord = null } + return true } function releaseBackgroundResponseHandoffParent(gate: ResponseHandoffGate): void { if ( !gate.backgroundOnly + || !gate.backgroundFinalized || gate.stoppedByUser || options.sessionKey.value !== gate.requestSessionKey || options.stream.isStreaming.value @@ -1807,16 +1884,21 @@ export function useChatSend(options: UseChatSendOptions) { async function finalizeAttemptBackgroundResponseHandoff( attempt: SendAttempt, acceptedSessionKey: string, - ): Promise { + ): Promise { const wal = options.pendingInputWal - if (!wal?.listHandoffs) return - const records = await wal.listHandoffs(attempt.requestSessionKey).catch(() => []) + 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 + if (!record) return true const gate: ResponseHandoffGate = { requestSessionKey: attempt.requestSessionKey, ownerRequestId: attempt.clientRequestId, @@ -1826,10 +1908,12 @@ export function useChatSend(options: UseChatSendOptions) { terminalResponse: false, authoritativeIdle: false, backgroundOnly: true, + backgroundFinalized: false, durableRecord: record, } - await finalizeBackgroundResponseHandoff(gate, acceptedSessionKey) + const finalized = await finalizeBackgroundResponseHandoff(gate, acceptedSessionKey) releaseBackgroundResponseHandoffParent(gate) + return finalized } async function markResponseHandoffFailed( @@ -1911,19 +1995,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) { @@ -2033,14 +2122,32 @@ 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, + ): Promise { + const gate = beginResponseHandoff( + record.requestSessionKey, + record.ownerRequestId, + record, + ) + gate.backgroundOnly = true + try { + await finalizeBackgroundResponseHandoff(gate, targetSessionKey) + } finally { + finishResponseHandoff(gate) + } + } + function restoreResponseHandoffDraft(record: ResponseHandoffWalRecord): boolean { if (options.sessionKey.value !== record.requestSessionKey) return false if (record.restoreComposerOnFailure === false) return true @@ -2106,7 +2213,7 @@ export function useChatSend(options: UseChatSendOptions) { } if (record.state === 'accepted' && record.acceptedSessionKey) { if (record.backgroundOnly) { - await deleteResponseHandoff(record) + await finalizeRecoveredBackgroundHandoff(record, record.acceptedSessionKey) } else { await finalizeRecoveredHandoff(record, record.acceptedSessionKey) } @@ -2121,12 +2228,20 @@ export function useChatSend(options: UseChatSendOptions) { params: replayRecord.params, }) const targetSessionKey = response.sessionKey || replayRecord.requestSessionKey - await finalizeRecoveredHandoff(replayRecord, targetSessionKey) + if (replayRecord.backgroundOnly) { + await finalizeRecoveredBackgroundHandoff(replayRecord, targetSessionKey) + } else { + await finalizeRecoveredHandoff(replayRecord, targetSessionKey) + } break } catch (error) { const accepted = acceptedErrorInfo(error) if (accepted?.sessionKey) { - await finalizeRecoveredHandoff(replayRecord, accepted.sessionKey) + if (replayRecord.backgroundOnly) { + await finalizeRecoveredBackgroundHandoff(replayRecord, accepted.sessionKey) + } else { + await finalizeRecoveredHandoff(replayRecord, accepted.sessionKey) + } break } const rpcError = error as RpcClientError | null | undefined @@ -3645,9 +3760,20 @@ export function useChatSend(options: UseChatSendOptions) { if (responseHandoff) { responseHandoff.acceptedTaskId = taskId responseHandoff.terminalResponse = Boolean(terminalStatus) - await finalizeBackgroundResponseHandoff(responseHandoff, acceptedSessionKey) + const finalized = await finalizeBackgroundResponseHandoff( + responseHandoff, + acceptedSessionKey, + ) + if (!finalized) { + attempt.acceptanceResolved = false + recoveredAttempt = attempt + scheduleAcceptanceRecovery(attempt) + } } - if (recoveredAttempt?.clientRequestId === attempt.clientRequestId) { + if ( + attempt.acceptanceResolved + && recoveredAttempt?.clientRequestId === attempt.clientRequestId + ) { recoveredAttempt = null } consumeAcceptedSessionIntent(attempt) @@ -3843,6 +3969,7 @@ export function useChatSend(options: UseChatSendOptions) { } if ( acceptedError + && acceptedResponseOwnsVisibleTranscript && recoveredAttempt?.clientRequestId === attempt.clientRequestId ) { recoveredAttempt = null @@ -3860,10 +3987,21 @@ export function useChatSend(options: UseChatSendOptions) { } if (responseHandoff) { responseHandoff.terminalResponse = acceptedError.terminalWithoutTask - await finalizeBackgroundResponseHandoff( + 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 From 2d562531c9985f4373a0b4a7b4dc18e509a076bb Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 23:11:20 +0800 Subject: [PATCH 14/24] Keep receipt recovery alive through WAL deletion --- .../chat/useChatSend.attachments.test.ts | 30 +++++++++++++++---- .../src/composables/chat/useChatSend.ts | 5 ++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 3215214628..6d3a135f72 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3229,7 +3229,10 @@ describe('useChatSend attachment payloads', () => { expect(await pendingInputWal.listHandoffs!()).toEqual([]) }) - it('keeps the recovery worker until the background-only accepted CAS is durable', async () => { + async function expectBackgroundCasRetry( + shouldFailTransition: (record: ResponseHandoffWalRecord | null) => boolean, + expectedRetainedState: ResponseHandoffWalRecord['state'], + ) { vi.useFakeTimers() try { const { @@ -3240,12 +3243,12 @@ describe('useChatSend attachment payloads', () => { messageActions, } = makeEditedMessageState('edited question') const baseWal = memoryHandoffWal() - let failAcceptedTransition = true + let failTransition = true const pendingInputWal: PendingInputWal = { ...baseWal, compareAndSwapHandoff: vi.fn(async (owner, walOwner, revision, record) => { - if (record?.state === 'accepted' && failAcceptedTransition) { - failAcceptedTransition = false + if (shouldFailTransition(record) && failTransition) { + failTransition = false return { applied: false, record: (await baseWal.listHandoffs!())[0] || null } } return baseWal.compareAndSwapHandoff!(owner, walOwner, revision, record) @@ -3282,7 +3285,10 @@ describe('useChatSend attachment payloads', () => { expect(acceptanceRecoveryPending.value).toBe(true) expect(await pendingInputWal.listHandoffs!()).toEqual([ - expect.objectContaining({ state: 'submitting', backgroundOnly: true }), + expect.objectContaining({ + state: expectedRetainedState, + backgroundOnly: true, + }), ]) await vi.advanceTimersByTimeAsync(250) @@ -3292,7 +3298,19 @@ describe('useChatSend attachment payloads', () => { } 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('keeps the recovery worker when background-only WAL persistence fails', async () => { vi.useFakeTimers() diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 248aafead2..8781b32d42 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -1859,10 +1859,11 @@ export function useChatSend(options: UseChatSendOptions) { gate.ownerRequestId, ).catch(() => false) if (queueReleased !== true) return false - gate.backgroundFinalized = true - if (gate.durableRecord && await deleteResponseHandoff(gate.durableRecord)) { + if (gate.durableRecord) { + if (!await deleteResponseHandoff(gate.durableRecord)) return false gate.durableRecord = null } + gate.backgroundFinalized = true return true } From e643a327fd28b5f9d8ef24c7a38701caa99c90dc Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 23:18:22 +0800 Subject: [PATCH 15/24] Fence background receipt recovery by session --- .../chat/useChatSend.attachments.test.ts | 169 ++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 51 +++++- 2 files changed, 217 insertions(+), 3 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 6d3a135f72..5ef78ac9f2 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3130,6 +3130,72 @@ describe('useChatSend attachment payloads', () => { 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('replays a background-only submitting WAL without adopting its child', async () => { const parent = 'agent:main:webchat:parent-background' const child = 'agent:main:webchat:child-background' @@ -3312,6 +3378,68 @@ describe('useChatSend attachment payloads', () => { }, ) + 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('keeps the recovery worker when background-only WAL persistence fails', async () => { vi.useFakeTimers() try { @@ -3763,6 +3891,47 @@ describe('useChatSend attachment payloads', () => { }, ) + 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).not.toHaveBeenCalled() + expect(sessionKey.value).toBe('agent:main:webchat:selected-during-replay') + }) + it('does not start async queue persistence for a fork edit while work is active', async () => { const { sessionKey, diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 8781b32d42..8dfec6a18a 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -2149,6 +2149,36 @@ export function useChatSend(options: UseChatSendOptions) { } } + async function retireFailedBackgroundHandoff( + record: ResponseHandoffWalRecord, + ): Promise { + const gate = beginResponseHandoff( + record.requestSessionKey, + record.ownerRequestId, + record, + ) + gate.backgroundOnly = true + try { + 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 + if (!await deleteResponseHandoff(record)) return + gate.durableRecord = null + gate.backgroundFinalized = true + } finally { + finishResponseHandoff(gate) + } + } + function restoreResponseHandoffDraft(record: ResponseHandoffWalRecord): boolean { if (options.sessionKey.value !== record.requestSessionKey) return false if (record.restoreComposerOnFailure === false) return true @@ -2207,7 +2237,9 @@ export function useChatSend(options: UseChatSendOptions) { continue } if (record.state === 'failed') { - if (restoreResponseHandoffDraft(record)) { + if (record.backgroundOnly) { + await retireFailedBackgroundHandoff(record) + } else if (restoreResponseHandoffDraft(record)) { await deleteResponseHandoff(record) } continue @@ -3718,6 +3750,13 @@ 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) { @@ -3751,7 +3790,10 @@ export function useChatSend(options: UseChatSendOptions) { if (acceptedSessionKey === requestSessionKey) { noteAcceptedTask(res, requestSessionKey) } - if (acceptedSessionKey === requestSessionKey) { + if ( + options.sessionKey.value === requestSessionKey + && acceptedSessionKey === requestSessionKey + ) { options.trackBackgroundReceiptTask?.( attempt.clientMessageId, taskId, @@ -3979,7 +4021,10 @@ export function useChatSend(options: UseChatSendOptions) { if (acceptedError && !acceptedResponseOwnsVisibleTranscript) { attempt.acceptanceResolved = true attempt.acceptedSessionKey = acceptedError.sessionKey || requestSessionKey - if (attempt.acceptedSessionKey === requestSessionKey) { + if ( + options.sessionKey.value === requestSessionKey + && attempt.acceptedSessionKey === requestSessionKey + ) { options.trackBackgroundReceiptTask?.( attempt.clientMessageId, '', From 951adc4de665a9bf47e16ce5f88a19fa8acc7f62 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 23:30:15 +0800 Subject: [PATCH 16/24] Quarantine automatic background receipt recovery --- .../chat/useChatSend.attachments.test.ts | 117 +++++++++++++++++- .../src/composables/chat/useChatSend.ts | 63 +++++++++- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 5ef78ac9f2..6e2604de6d 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3196,7 +3196,7 @@ describe('useChatSend attachment payloads', () => { expect(await pendingInputWal.listHandoffs!()).toEqual([]) }) - it('replays a background-only submitting WAL without adopting its child', async () => { + 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' @@ -3440,6 +3440,121 @@ describe('useChatSend attachment payloads', () => { 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('keeps the recovery worker when background-only WAL persistence fails', async () => { vi.useFakeTimers() try { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 8dfec6a18a..bcc53df8a2 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -1335,12 +1335,11 @@ export function useChatSend(options: UseChatSendOptions) { async function settleRecoveredAcceptance( attempt: SendAttempt, response: TurnSendResponse, + forceBackground = false, ): Promise { const acceptedSessionKey = response.sessionKey || attempt.requestSessionKey - let responseOwnsVisibleTranscript = !recoveredAttemptHasUnrelatedComposer( - attempt, - captureComposerSnapshot(), - ) + let responseOwnsVisibleTranscript = !forceBackground + && !recoveredAttemptHasUnrelatedComposer(attempt, captureComposerSnapshot()) && acceptedSessionKey === attempt.requestSessionKey && validateAttemptMessageEditTranscript(attempt) acknowledgeAttemptPromptAnnotations(attempt, response, responseOwnsVisibleTranscript) @@ -1417,6 +1416,43 @@ export function useChatSend(options: UseChatSendOptions) { return abortRecoveredAcceptedTask(attempt) } + 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 || '', + terminalResponse: false, + authoritativeIdle: false, + backgroundOnly: true, + backgroundFinalized: false, + durableRecord: record, + } + return markResponseHandoffBackgroundOnly(gate) + } + function scheduleAcceptanceRecovery(attempt: SendAttempt) { if ((attempt.acceptanceResolved && !attempt.stopRequested) || !attempt.acceptanceRequest) return const key = acceptanceAttemptKey(attempt) @@ -1436,11 +1472,21 @@ export function useChatSend(options: UseChatSendOptions) { } 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) @@ -1455,7 +1501,9 @@ export function useChatSend(options: UseChatSendOptions) { } : {}), } - if (await settleRecoveredAcceptance(attempt, response)) return + if (await settleRecoveredAcceptance(attempt, response, backgroundReceiptReplayStarted)) { + return + } continue } if (rpcError?.accepted === false) { @@ -1483,6 +1531,9 @@ export function useChatSend(options: UseChatSendOptions) { void options.reconcileTaskOwnership?.() } } finally { + if (backgroundReceiptReplayStarted) { + options.finishBackgroundReceiptReplay?.(attempt.clientMessageId) + } attempt.acceptanceInFlight = false } } From 84def130c1000b3d06873af31a7aef395ab943f0 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 06:07:18 +0800 Subject: [PATCH 17/24] Preserve regenerate receipt adoption --- .../chat/useChatSend.attachments.test.ts | 49 +++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 12 ++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 6e2604de6d..524adbcf7c 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2382,6 +2382,7 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, validateActiveProjectBeforeSend, @@ -4257,6 +4258,7 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) @@ -4305,6 +4307,7 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) @@ -4508,6 +4511,7 @@ describe('useChatSend attachment payloads', () => { inputText, pendingForkBeforeMessageId, messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, validateMessageEditOwner: messageActions.validateEditOwner, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, }) @@ -5451,6 +5455,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' diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index bcc53df8a2..466e3c37c8 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -235,6 +235,7 @@ interface ComposerSnapshot { initialCollaborationMode: CollaborationMode | null initialRoutingMode: GatewayModelRoutingMode | null queueOwnerRequestId: string | null + messageEditActive: boolean messageEditGeneration: number | null } @@ -833,6 +834,7 @@ export function useChatSend(options: UseChatSendOptions) { queueOwnerRequestId: queueOwnerContext?.sessionKey === options.sessionKey.value ? queueOwnerContext.ownerRequestId : null, + messageEditActive: options.messageEditActive?.value === true, messageEditGeneration: options.messageEditGeneration?.value ?? null, } } @@ -900,6 +902,7 @@ export function useChatSend(options: UseChatSendOptions) { && current.workspaceId === owner.workspaceId && current.initialCollaborationMode === owner.initialCollaborationMode && current.initialRoutingMode === owner.initialRoutingMode + && current.messageEditActive === owner.messageEditActive && current.messageEditGeneration === owner.messageEditGeneration ) } @@ -934,7 +937,9 @@ export function useChatSend(options: UseChatSendOptions) { snapshot: ComposerSnapshot, validateTranscript = false, ): boolean { - if (snapshot.messageEditGeneration === null) return true + 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 @@ -3626,7 +3631,10 @@ export function useChatSend(options: UseChatSendOptions) { ...(sendOpts.replayCoordination ? { replayCoordinationKey: sendOpts.replayCoordination.key } : {}), - ...(forkBeforeMessageId && sendOpts.composerSnapshot?.messageEditGeneration != null + ...( + forkBeforeMessageId + && sendOpts.composerSnapshot?.messageEditActive === true + && sendOpts.composerSnapshot.messageEditGeneration != null ? { messageEditTranscriptOwner: { generation: sendOpts.composerSnapshot.messageEditGeneration, From 4ebe861a3a07e41033e06d7dd6507e914f59a89b Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 10:43:05 +0800 Subject: [PATCH 18/24] Replay superseded edit receipts in background --- .../chat/useChatSend.attachments.test.ts | 90 +++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 34 ++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 524adbcf7c..6cbd7a2e6e 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2609,6 +2609,96 @@ describe('useChatSend attachment payloads', () => { }, ) + it('settles an older Edit receipt in the background before sending the newer Edit click', async () => { + const oldChildSessionKey = 'agent:main:webchat:old-edit-child' + 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 adoptResponseSession = vi.fn(async (key: string) => { + sessionKey.value = key + }) + const rpc = { + call: vi.fn() + .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) + .mockResolvedValueOnce({ + sessionKey: oldChildSessionKey, + task_id: 'old-edit-task', + }) + .mockResolvedValueOnce({ + sessionKey: newChildSessionKey, + task_id: 'new-edit-task', + }), + } + const harness = makeOptions({ + rpc, + sessionKey, + messages, + inputText, + pendingForkBeforeMessageId, + pendingInputWal, + beginBackgroundReceiptReplay, + adoptResponseSession, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) + + 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') + + await harness.api.onSend() + + expect(rpc.call).toHaveBeenCalledTimes(3) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(oldParams) + 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, diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 466e3c37c8..df3ac0e7e8 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -2850,10 +2850,21 @@ export function useChatSend(options: UseChatSendOptions) { exactReplayAttempt, replayComposerSnapshot, ) + const replayingSupersededEditOwner = Boolean( + preserveUnrelatedBranch + && replayComposerSnapshot.messageEditActive + && replayComposerSnapshot.messageEditGeneration !== null + && exactReplayAttempt.messageEditTranscriptOwner + && replayComposerSnapshot.messageEditGeneration + !== exactReplayAttempt.messageEditTranscriptOwner.generation, + ) if (!messageEditOwnerMatchesSnapshot(replayComposerSnapshot)) return - if (!validateAttemptMessageEditTranscript(exactReplayAttempt)) return + if ( + !replayingSupersededEditOwner + && !validateAttemptMessageEditTranscript(exactReplayAttempt) + ) return if (replayBlockedReason?.value) return - await dispatchSend(exactReplayAttempt.text, { + const replayOutcome = await dispatchSend(exactReplayAttempt.text, { composerText, composerSnapshot: replayComposerSnapshot, promptAnnotationIds: exactReplayAttempt.promptAnnotationIds, @@ -2887,10 +2898,18 @@ export function useChatSend(options: UseChatSendOptions) { validateTranscript: false, }, ) - && validateAttemptMessageEditTranscript(exactReplayAttempt) + && ( + replayingSupersededEditOwner + || validateAttemptMessageEditTranscript(exactReplayAttempt) + ) ), }) - return + if (!replayingSupersededEditOwner || replayOutcome !== 'accepted') return + if ( + options.sessionKey.value !== requestSessionKey + || !sameComposerOwnershipSnapshot(captureComposerSnapshot(), replayComposerSnapshot) + || !messageEditOwnerMatchesSnapshot(replayComposerSnapshot, true) + ) return } if (hasPayload) { @@ -3346,8 +3365,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] From 90280848fe095e07696110c581340038a5c58576 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 11:06:24 +0800 Subject: [PATCH 19/24] Retire rejected background receipts durably --- .../chat/useChatSend.attachments.test.ts | 492 +++++++++++++++--- .../src/composables/chat/useChatSend.ts | 191 ++++++- .../pendingInputWal.atomicHandoff.test.ts | 76 +++ .../src/utils/chat/pendingInputWal.ts | 10 +- 4 files changed, 666 insertions(+), 103 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 6cbd7a2e6e..1a5aeaa332 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, @@ -2609,95 +2610,103 @@ describe('useChatSend attachment payloads', () => { }, ) - it('settles an older Edit receipt in the background before sending the newer Edit click', async () => { - const oldChildSessionKey = 'agent:main:webchat:old-edit-child' - 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 adoptResponseSession = vi.fn(async (key: string) => { - sessionKey.value = key - }) - const rpc = { - call: vi.fn() - .mockRejectedValueOnce(new RpcTransportError('Connection closed', null)) - .mockResolvedValueOnce({ - sessionKey: oldChildSessionKey, - task_id: 'old-edit-task', - }) - .mockResolvedValueOnce({ - sessionKey: newChildSessionKey, - task_id: 'new-edit-task', - }), - } - const harness = makeOptions({ - rpc, - sessionKey, - messages, - inputText, - pendingForkBeforeMessageId, - pendingInputWal, - beginBackgroundReceiptReplay, - adoptResponseSession, - messageEditGeneration: messageActions.editGeneration, - messageEditActive: messageActions.editActive, - validateMessageEditOwner: messageActions.validateEditOwner, - commitMessageEdit: messageActions.commitEdit, - adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, - }) - - await harness.api.onSend() - const oldParams = rpc.call.mock.calls[0]?.[1] - const oldRequestId = oldParams?.clientRequestId - const oldClientMessageId = oldParams?.clientMessageId + it.each([ + ['accepted', () => Promise.resolve({ + sessionKey: 'agent:main:webchat:old-edit-child', + task_id: 'old-edit-task', + })], + ['definitely rejected', () => Promise.reject(Object.assign( + new Error('old Edit was rejected'), + { accepted: false, retryable: false }, + ))], + ] as const)( + 'settles an older %s Edit receipt before sending the newer Edit click', + async (_label, settleOldReceipt) => { + 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 adoptResponseSession = vi.fn(async (key: string) => { + sessionKey.value = key + }) + 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, + adoptResponseSession, + messageEditGeneration: messageActions.editGeneration, + messageEditActive: messageActions.editActive, + validateMessageEditOwner: messageActions.validateEditOwner, + commitMessageEdit: messageActions.commitEdit, + adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + }) - 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') + 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') - await harness.api.onSend() + await harness.api.onSend() - expect(rpc.call).toHaveBeenCalledTimes(3) - expect(rpc.call.mock.calls[1]?.[1]).toEqual(oldParams) - 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([]) - }) + expect(rpc.call).toHaveBeenCalledTimes(3) + expect(rpc.call.mock.calls[1]?.[1]).toEqual(oldParams) + 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 { @@ -3338,6 +3347,209 @@ describe('useChatSend attachment payloads', () => { expect(await pendingInputWal.listHandoffs?.()).toEqual([]) }) + it('quarantines task, turn, and session events during crashed background receipt recovery', async () => { + 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() + taskOwnership.applySnapshot({ + run_status: 'running', + active_task: { task_id: 'current-task', status: 'running' }, + }, true) + const activeStreamTaskId = ref('current-task') + const messages = ref([{ + role: 'assistant', + text: 'current history', + ts: null, + }]) + const historyOwner = messages.value + let beginReplay = (_clientMessageId: string, _holdHistory?: boolean) => {} + let finishReplay = (_clientMessageId: string) => {} + const beginBackgroundReceiptReplay = vi.fn((id: string, holdHistory?: boolean) => { + beginReplay(id, holdHistory) + }) + const finishBackgroundReceiptReplay = vi.fn((id: string) => { + finishReplay(id) + }) + const applySessionRunState = vi.fn() + const scheduleHistorySync = vi.fn() + const recovery = makeOptions({ + rpc, + sessionKey: ref(parent), + messages, + pendingInputWal, + taskOwnership, + activeStreamTaskId, + beginBackgroundReceiptReplay, + finishBackgroundReceiptReplay, + scheduleHistorySync, + 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.stream.isStreaming.value = true + 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: vi.fn(), + popAllPendingIntoComposer: vi.fn(() => false), + saveWidgetState: vi.fn(), + loadCurrentSessionUsage: vi.fn(), + }))! + beginReplay = rpcEvents.beginBackgroundReceiptReplay + finishReplay = rpcEvents.finishBackgroundReceiptReplay + + 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('session.event.text_delta', { + session_key: parent, + task_id: 'recovered-task', + stream_seq: 1, + text: 'old recovered output', + }) + rpcEvents.onConversationEvent({ + kind: 'sessions-changed', + payload: { + session_key: parent, + 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('current-task') + expect(taskOwnership.runningTaskId.value).toBe('current-task') + expect(recovery.stream.appendDelta).not.toHaveBeenCalled() + expect(applySessionRunState).not.toHaveBeenCalled() + expect(messages.value).toBe(historyOwner) + expect(messages.value).toEqual([expect.objectContaining({ text: 'current history' })]) + expect(scheduleHistorySync).not.toHaveBeenCalled() + + resolveRecovery({ sessionKey: child, task_id: 'recovered-task' }) + await restoring + + expect(finishBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId) + expect(await pendingInputWal.listHandoffs!()).toEqual([]) + } finally { + scope.stop() + } + }) + it('releases a crashed accepted background-only owner back to the parent drain', async () => { const parent = 'agent:main:webchat:accepted-background-parent' const child = 'agent:main:webchat:accepted-background-child' @@ -3646,6 +3858,114 @@ describe('useChatSend attachment payloads', () => { } }) + 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 { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index df3ac0e7e8..8d0563f8b8 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -201,6 +201,9 @@ interface SendAttempt { } acceptanceResolved?: boolean acceptanceInFlight?: boolean + /** A background receipt was rejected and only its durable retirement remains. */ + backgroundRejectionPending?: boolean + backgroundRejectionError?: unknown acceptedTaskId?: string acceptedSessionKey?: string stopAbortPromise?: Promise | null @@ -257,6 +260,8 @@ interface DispatchSendOptions { 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 /** Preserve an explicit empty attachment list on the chat.send wire. */ includeEmptyAttachments?: boolean /** Revalidate protocol-owned sends after every awaited pre-dispatch step. */ @@ -1471,6 +1476,20 @@ 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 @@ -1512,6 +1531,13 @@ export function useChatSend(options: UseChatSendOptions) { 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 ( @@ -1973,12 +1999,12 @@ export function useChatSend(options: UseChatSendOptions) { return finalized } - async function markResponseHandoffFailed( + 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', @@ -1997,14 +2023,89 @@ 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 || '', + 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, @@ -2207,7 +2308,8 @@ export function useChatSend(options: UseChatSendOptions) { async function retireFailedBackgroundHandoff( record: ResponseHandoffWalRecord, - ): Promise { + rejectionError?: unknown, + ): Promise { const gate = beginResponseHandoff( record.requestSessionKey, record.ownerRequestId, @@ -2215,6 +2317,9 @@ export function useChatSend(options: UseChatSendOptions) { ) gate.backgroundOnly = true try { + if (rejectionError !== undefined) { + return retireRejectedBackgroundResponseHandoff(gate, rejectionError) + } if ( options.sessionKey.value === gate.requestSessionKey && options.hasPendingQueueWork?.() !== true @@ -2226,10 +2331,11 @@ export function useChatSend(options: UseChatSendOptions) { gate.requestSessionKey, gate.ownerRequestId, ).catch(() => false) - if (queueReleased !== true) return - if (!await deleteResponseHandoff(record)) return + if (queueReleased !== true) return false + if (!await deleteResponseHandoff(record)) return false gate.durableRecord = null gate.backgroundFinalized = true + return true } finally { finishResponseHandoff(gate) } @@ -2271,6 +2377,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 @@ -2308,6 +2420,13 @@ export function useChatSend(options: UseChatSendOptions) { } continue } + if (record.backgroundOnly) { + options.beginBackgroundReceiptReplay?.( + record.clientMessageId, + options.messageEditActive?.value === true, + ) + backgroundReceiptClientMessageId = record.clientMessageId + } let replayRecord = record let refreshedExpiredAttachments = false while (true) { @@ -2374,7 +2493,9 @@ export function useChatSend(options: UseChatSendOptions) { continue } } - if (definitelyRejected && rpcError?.retryable === false) { + if (definitelyRejected && replayRecord.backgroundOnly) { + await retireFailedBackgroundHandoff(replayRecord, error) + } else if (definitelyRejected && rpcError?.retryable === false) { await wal.putHandoff?.({ ...replayRecord, state: 'failed', @@ -2387,13 +2508,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 @@ -2864,6 +2987,7 @@ export function useChatSend(options: UseChatSendOptions) { && !validateAttemptMessageEditTranscript(exactReplayAttempt) ) return if (replayBlockedReason?.value) return + let rejectedReplayRetired = false const replayOutcome = await dispatchSend(exactReplayAttempt.text, { composerText, composerSnapshot: replayComposerSnapshot, @@ -2886,6 +3010,9 @@ export function useChatSend(options: UseChatSendOptions) { preserveComposer: preserveUnrelatedBranch, suppressRejectedFailureMessage: preserveUnrelatedBranch, backgroundReceiptReplay: preserveUnrelatedBranch, + onBackgroundRejectionRetired: () => { + rejectedReplayRetired = true + }, preDispatchGuard: stage => ( forkSnapshotPreDispatchAllowed( replayComposerSnapshot, @@ -2904,7 +3031,10 @@ export function useChatSend(options: UseChatSendOptions) { ) ), }) - if (!replayingSupersededEditOwner || replayOutcome !== 'accepted') return + if ( + !replayingSupersededEditOwner + || (replayOutcome !== 'accepted' && !rejectedReplayRetired) + ) return if ( options.sessionKey.value !== requestSessionKey || !sameComposerOwnershipSnapshot(captureComposerSnapshot(), replayComposerSnapshot) @@ -3816,6 +3946,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 || ( @@ -4253,6 +4384,30 @@ export function useChatSend(options: UseChatSendOptions) { 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 + scheduleAcceptanceRecovery(attempt) + } + } if (options.sessionKey.value !== requestSessionKey) { rememberRetryableAttempt(false) recordSessionNavigationDiag('send.error.stale', { @@ -4275,9 +4430,13 @@ 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) } } @@ -4293,7 +4452,7 @@ export function useChatSend(options: UseChatSendOptions) { // owner's transcript. return acceptedError ? 'accepted' : 'retryable_failure' } - rememberRetryableAttempt(true) + if (!backgroundRejectionRetired) rememberRetryableAttempt(true) if ( acceptedError || (!backgroundReceiptReplay && !sendOpts.suppressRejectedFailureMessage) 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 2849dcf323..7b26f34d1c 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -503,7 +503,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') } From 3f91f888e92c13c59183aef5e8ae98d6a70234ef Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 11:20:48 +0800 Subject: [PATCH 20/24] Complete background receipt recovery --- .../chat/useChatSend.attachments.test.ts | 176 +++++++++++++++++- .../src/composables/chat/useChatSend.ts | 73 +++++++- 2 files changed, 232 insertions(+), 17 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 1a5aeaa332..8f9cb77def 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2614,14 +2614,22 @@ describe('useChatSend attachment payloads', () => { ['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) => { + async (_label, settleOldReceipt, delayRetirement, shouldSendNewEdit) => { const newChildSessionKey = 'agent:main:webchat:new-edit-child' const { sessionKey, @@ -2632,6 +2640,9 @@ describe('useChatSend attachment payloads', () => { } = 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 }) @@ -2652,6 +2663,7 @@ describe('useChatSend attachment payloads', () => { pendingForkBeforeMessageId, pendingInputWal, beginBackgroundReceiptReplay, + recoverPendingQueueHandoff, adoptResponseSession, messageEditGeneration: messageActions.editGeneration, messageEditActive: messageActions.editActive, @@ -2689,10 +2701,30 @@ describe('useChatSend attachment payloads', () => { expect(pendingForkBeforeMessageId.value).toBe('new-edit-target') expect(inputText.value).toBe('newer edited question') - await harness.api.onSend() + if (delayRetirement) vi.useFakeTimers() + try { + const sendNewEdit = harness.api.onSend() + if (delayRetirement) { + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + if (!shouldSendNewEdit) inputText.value = 'changed while retirement was pending' + await vi.advanceTimersByTimeAsync(250) + } + await sendNewEdit + } finally { + if (delayRetirement) vi.useRealTimers() + } - expect(rpc.call).toHaveBeenCalledTimes(3) + 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', @@ -3296,6 +3328,129 @@ describe('useChatSend attachment payloads', () => { 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' @@ -3428,6 +3583,10 @@ describe('useChatSend attachment payloads', () => { }) const applySessionRunState = vi.fn() const scheduleHistorySync = vi.fn() + let resolveQueueRelease!: (released: boolean) => void + const recoverPendingQueueHandoff = vi.fn(() => new Promise(resolve => { + resolveQueueRelease = resolve + })) const recovery = makeOptions({ rpc, sessionKey: ref(parent), @@ -3438,6 +3597,7 @@ describe('useChatSend attachment payloads', () => { beginBackgroundReceiptReplay, finishBackgroundReceiptReplay, scheduleHistorySync, + recoverPendingQueueHandoff, messageEditActive: ref(true), prepareAttachmentsForSend: vi.fn(async ({ attachments }) => { const attachment = attachments?.[0] @@ -3501,6 +3661,9 @@ describe('useChatSend attachment payloads', () => { expect(rpc.call.mock.calls[1]?.[1]?.attachments?.[0]?.file_uuid).toBe( 'refreshed-recovery-file', ) + resolveRecovery({ sessionKey: child, task_id: 'recovered-task' }) + await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) + expect(finishBackgroundReceiptReplay).not.toHaveBeenCalled() const deliver = (eventName: string, payload: Record) => { rpcEvents.onConversationEvent({ kind: 'conversation', @@ -3512,7 +3675,6 @@ describe('useChatSend attachment payloads', () => { deliver('task.queued', { session_key: parent, task_id: 'recovered-task', - client_message_id: clientMessageId, }) deliver('session.event.text_delta', { session_key: parent, @@ -3540,7 +3702,7 @@ describe('useChatSend attachment payloads', () => { expect(messages.value).toEqual([expect.objectContaining({ text: 'current history' })]) expect(scheduleHistorySync).not.toHaveBeenCalled() - resolveRecovery({ sessionKey: child, task_id: 'recovered-task' }) + resolveQueueRelease(true) await restoring expect(finishBackgroundReceiptReplay).toHaveBeenCalledWith(clientMessageId) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 8d0563f8b8..cd9cd2a774 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -262,6 +262,8 @@ interface DispatchSendOptions { 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. */ @@ -1463,10 +1465,14 @@ export function useChatSend(options: UseChatSendOptions) { return markResponseHandoffBackgroundOnly(gate) } - function scheduleAcceptanceRecovery(attempt: SendAttempt) { - if ((attempt.acceptanceResolved && !attempt.stopRequested) || !attempt.acceptanceRequest) return + 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 @@ -1576,6 +1582,7 @@ export function useChatSend(options: UseChatSendOptions) { }) acceptanceRecoveryWorkers.set(key, operation) noteAcceptanceRecoveryChanged() + return operation } function pendingQueueOwner(): PendingQueueOwner | undefined { @@ -2341,6 +2348,41 @@ export function useChatSend(options: UseChatSendOptions) { } } + 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 @@ -2406,7 +2448,7 @@ export function useChatSend(options: UseChatSendOptions) { } if (record.state === 'failed') { if (record.backgroundOnly) { - await retireFailedBackgroundHandoff(record) + await retireBackgroundHandoffUntilComplete(record) } else if (restoreResponseHandoffDraft(record)) { await deleteResponseHandoff(record) } @@ -2437,6 +2479,11 @@ export function useChatSend(options: UseChatSendOptions) { }) const targetSessionKey = response.sessionKey || replayRecord.requestSessionKey if (replayRecord.backgroundOnly) { + options.trackBackgroundReceiptTask?.( + replayRecord.clientMessageId, + acceptedTaskId(response), + terminalResponseStatus(response), + ) await finalizeRecoveredBackgroundHandoff(replayRecord, targetSessionKey) } else { await finalizeRecoveredHandoff(replayRecord, targetSessionKey) @@ -2494,7 +2541,7 @@ export function useChatSend(options: UseChatSendOptions) { } } if (definitelyRejected && replayRecord.backgroundOnly) { - await retireFailedBackgroundHandoff(replayRecord, error) + await retireBackgroundHandoffUntilComplete(replayRecord, error) } else if (definitelyRejected && rpcError?.retryable === false) { await wal.putHandoff?.({ ...replayRecord, @@ -2988,6 +3035,7 @@ export function useChatSend(options: UseChatSendOptions) { ) return if (replayBlockedReason?.value) return let rejectedReplayRetired = false + let rejectedReplayRetirement: Promise | null = null const replayOutcome = await dispatchSend(exactReplayAttempt.text, { composerText, composerSnapshot: replayComposerSnapshot, @@ -3013,6 +3061,9 @@ export function useChatSend(options: UseChatSendOptions) { onBackgroundRejectionRetired: () => { rejectedReplayRetired = true }, + onBackgroundRejectionRetirement: (retirement) => { + rejectedReplayRetirement = retirement + }, preDispatchGuard: stage => ( forkSnapshotPreDispatchAllowed( replayComposerSnapshot, @@ -3031,10 +3082,11 @@ export function useChatSend(options: UseChatSendOptions) { ) ), }) - if ( - !replayingSupersededEditOwner - || (replayOutcome !== 'accepted' && !rejectedReplayRetired) - ) return + if (!replayingSupersededEditOwner) return + if (replayOutcome !== 'accepted' && !rejectedReplayRetired) { + if (!rejectedReplayRetirement) return + await rejectedReplayRetirement + } if ( options.sessionKey.value !== requestSessionKey || !sameComposerOwnershipSnapshot(captureComposerSnapshot(), replayComposerSnapshot) @@ -4405,7 +4457,8 @@ export function useChatSend(options: UseChatSendOptions) { attempt.backgroundRejectionPending = true attempt.backgroundRejectionError = err recoveredAttempt = attempt - scheduleAcceptanceRecovery(attempt) + const retirement = scheduleAcceptanceRecovery(attempt) + if (retirement) sendOpts.onBackgroundRejectionRetirement?.(retirement) } } if (options.sessionKey.value !== requestSessionKey) { From 2fc6c49e7234625f1e6757b3ed767964163b14ea Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 11:39:23 +0800 Subject: [PATCH 21/24] Fence recovered receipt projection and retries --- .../chat/useChatSend.attachments.test.ts | 57 +++++++++++++++---- .../src/composables/chat/useChatSend.ts | 20 +++++-- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 8f9cb77def..026f4e3b35 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2704,12 +2704,16 @@ describe('useChatSend attachment payloads', () => { if (delayRetirement) vi.useFakeTimers() try { const sendNewEdit = harness.api.onSend() + let repeatedSend: Promise | null = null if (delayRetirement) { await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) + repeatedSend = 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) } - await sendNewEdit + await Promise.all([sendNewEdit, repeatedSend]) } finally { if (delayRetirement) vi.useRealTimers() } @@ -3562,11 +3566,7 @@ describe('useChatSend attachment payloads', () => { }), } as unknown as UseChatSendOptions['rpc'] const taskOwnership = useChatTaskOwnership() - taskOwnership.applySnapshot({ - run_status: 'running', - active_task: { task_id: 'current-task', status: 'running' }, - }, true) - const activeStreamTaskId = ref('current-task') + const activeStreamTaskId = ref('') const messages = ref([{ role: 'assistant', text: 'current history', @@ -3575,14 +3575,27 @@ describe('useChatSend attachment payloads', () => { const historyOwner = messages.value let beginReplay = (_clientMessageId: string, _holdHistory?: boolean) => {} let finishReplay = (_clientMessageId: string) => {} + let trackReplay = ( + _clientMessageId: string, + _taskId: string, + _terminal?: boolean | 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, + ) => { + trackReplay(id, taskId, terminal) + }) const applySessionRunState = vi.fn() const scheduleHistorySync = vi.fn() + const schedulePendingDrainAfterTerminal = vi.fn() let resolveQueueRelease!: (released: boolean) => void const recoverPendingQueueHandoff = vi.fn(() => new Promise(resolve => { resolveQueueRelease = resolve @@ -3596,6 +3609,7 @@ describe('useChatSend attachment payloads', () => { activeStreamTaskId, beginBackgroundReceiptReplay, finishBackgroundReceiptReplay, + trackBackgroundReceiptTask, scheduleHistorySync, recoverPendingQueueHandoff, messageEditActive: ref(true), @@ -3608,7 +3622,13 @@ describe('useChatSend attachment payloads', () => { return true }), }) - recovery.stream.isStreaming.value = 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, @@ -3643,13 +3663,14 @@ describe('useChatSend attachment payloads', () => { showCompactionToast: vi.fn(), showWarningToast: vi.fn(), scheduleHistorySync, - schedulePendingDrainAfterTerminal: vi.fn(), + 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() @@ -3661,8 +3682,19 @@ describe('useChatSend attachment payloads', () => { expect(rpc.call.mock.calls[1]?.[1]?.attachments?.[0]?.file_uuid).toBe( 'refreshed-recovery-file', ) - resolveRecovery({ sessionKey: child, task_id: 'recovered-task' }) + resolveRecovery({ + sessionKey: child, + task_id: 'recovered-task', + task_status: 'failed', + }) await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) + expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( + clientMessageId, + 'recovered-task', + false, + ) + expect(applySessionRunState).not.toHaveBeenCalled() + expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() expect(finishBackgroundReceiptReplay).not.toHaveBeenCalled() const deliver = (eventName: string, payload: Record) => { rpcEvents.onConversationEvent({ @@ -3685,7 +3717,7 @@ describe('useChatSend attachment payloads', () => { rpcEvents.onConversationEvent({ kind: 'sessions-changed', payload: { - session_key: parent, + session_key: child, reason: 'task_terminal', run_status: 'idle', changed_task: { task_id: 'recovered-task', status: 'succeeded' }, @@ -3694,10 +3726,11 @@ describe('useChatSend attachment payloads', () => { meta: {}, }) - expect(activeStreamTaskId.value).toBe('current-task') - expect(taskOwnership.runningTaskId.value).toBe('current-task') + expect(activeStreamTaskId.value).toBe('') + expect(taskOwnership.runningTaskId.value).toBe('') 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() diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index cd9cd2a774..5f75f28170 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -2482,7 +2482,10 @@ export function useChatSend(options: UseChatSendOptions) { options.trackBackgroundReceiptTask?.( replayRecord.clientMessageId, acceptedTaskId(response), - terminalResponseStatus(response), + targetSessionKey === replayRecord.requestSessionKey + && targetSessionKey === options.sessionKey.value + ? terminalResponseStatus(response) + : false, ) await finalizeRecoveredBackgroundHandoff(replayRecord, targetSessionKey) } else { @@ -3034,9 +3037,10 @@ export function useChatSend(options: UseChatSendOptions) { && !validateAttemptMessageEditTranscript(exactReplayAttempt) ) return if (replayBlockedReason?.value) return + const rejectionAlreadyPending = exactReplayAttempt.backgroundRejectionPending === true let rejectedReplayRetired = false let rejectedReplayRetirement: Promise | null = null - const replayOutcome = await dispatchSend(exactReplayAttempt.text, { + const dispatchExactReplay = () => dispatchSend(exactReplayAttempt.text, { composerText, composerSnapshot: replayComposerSnapshot, promptAnnotationIds: exactReplayAttempt.promptAnnotationIds, @@ -3082,11 +3086,19 @@ export function useChatSend(options: UseChatSendOptions) { ) ), }) - if (!replayingSupersededEditOwner) return - if (replayOutcome !== 'accepted' && !rejectedReplayRetired) { + const replayOutcome = rejectionAlreadyPending ? null : await dispatchExactReplay() + if (rejectionAlreadyPending) { + rejectedReplayRetirement = scheduleAcceptanceRecovery(exactReplayAttempt) + if (rejectedReplayRetirement) await rejectedReplayRetirement + if ( + exactReplayAttempt.backgroundRejectionPending + || !exactReplayAttempt.acceptanceResolved + ) return + } else if (replayOutcome !== 'accepted' && !rejectedReplayRetired) { if (!rejectedReplayRetirement) return await rejectedReplayRetirement } + if (!replayingSupersededEditOwner) return if ( options.sessionKey.value !== requestSessionKey || !sameComposerOwnershipSnapshot(captureComposerSnapshot(), replayComposerSnapshot) From 8019aa9cd95a9bdfedba44adb5c2735cf6ddd6a8 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 11:52:56 +0800 Subject: [PATCH 22/24] Serialize rejected receipt continuation --- .../chat/useChatRpcEventHandlers.ts | 38 +++++++--- .../chat/useChatSend.attachments.test.ts | 67 +++++++++++++---- .../src/composables/chat/useChatSend.ts | 71 ++++++++++++++----- opensquilla-webui/src/views/ChatView.vue | 5 +- 4 files changed, 142 insertions(+), 39 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index f4bf898b70..9c58c1f2f4 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -415,6 +415,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) interface BackgroundReceiptTask { clientMessageId: string terminalSeen: boolean + allowProjection: boolean } const pendingBackgroundReceiptClientIds = new Set() @@ -480,6 +481,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) clientMessageId: string, taskId: string, terminalSeen = false, + allowProjection = true, ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() @@ -493,6 +495,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) backgroundReceiptTasks.set(normalizedTaskId, { clientMessageId: normalizedClientId, terminalSeen: terminalSeen || existing?.terminalSeen === true, + allowProjection: allowProjection && existing?.allowProjection !== false, }) } @@ -500,6 +503,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) clientMessageId: string, taskId: string, terminal: boolean | string = false, + allowProjection = true, ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() @@ -507,10 +511,18 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) ? terminal.trim().toLowerCase() : terminal ? 'succeeded' : '' rememberBackgroundReceiptClient(normalizedClientId) - rememberBackgroundReceiptTask(normalizedClientId, normalizedTaskId, Boolean(terminalStatus)) + rememberBackgroundReceiptTask( + normalizedClientId, + normalizedTaskId, + Boolean(terminalStatus), + allowProjection, + ) if (terminalStatus) { if (normalizedTaskId) options.taskOwnership?.noteTerminal(normalizedTaskId) - if (!reconciledBackgroundReceiptClientIds.has(normalizedClientId)) { + if ( + allowProjection + && !reconciledBackgroundReceiptClientIds.has(normalizedClientId) + ) { dirtyBackgroundReceiptClientIds.add(normalizedClientId) flushBackgroundReceiptReconciliationIfReady() } @@ -519,7 +531,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) normalizedTaskId, terminalStatus, {}, - true, + allowProjection, ) } } @@ -598,16 +610,21 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) 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 } + return { + clientMessageId: tracked.clientMessageId, + taskId: identity.taskId, + allowProjection: tracked.allowProjection, + } } if ( identity.clientMessageId && backgroundReceiptClientIds.has(identity.clientMessageId) - ) return identity + ) return { ...identity, allowProjection: true } } return null } @@ -646,7 +663,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) if (!isCurrentSessionPayload(payload)) return false const identity = matchingBackgroundReceiptIdentity(payload) if (!identity) return false - const { clientMessageId: owner, taskId } = identity + const { clientMessageId: owner, taskId, allowProjection } = identity const terminalEvent = eventKind === 'sessions-changed' ? sessionChangeIsTerminal(payload) : isTerminalEvent(eventKind) @@ -661,8 +678,11 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) } else if (terminalEvent) { options.taskOwnership?.noteTerminal(taskId) markTaskSettled(payload) - rememberBackgroundReceiptTask(owner, taskId, true) - if (!reconciledBackgroundReceiptClientIds.has(owner)) { + rememberBackgroundReceiptTask(owner, taskId, true, allowProjection) + if ( + allowProjection + && !reconciledBackgroundReceiptClientIds.has(owner) + ) { dirtyBackgroundReceiptClientIds.add(owner) } flushBackgroundReceiptReconciliationIfReady() @@ -690,7 +710,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) || (eventKind === 'sessions-changed' ? '' : eventTaskTerminalStatus(eventKind)) || (eventKind === 'turn-failed' ? 'failed' : 'succeeded'), terminalTask || payload, - !hasContinuation, + !hasContinuation && allowProjection, ) } return true diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 026f4e3b35..339c4178c0 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -2646,6 +2646,15 @@ describe('useChatSend attachment payloads', () => { 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)) @@ -2670,6 +2679,7 @@ describe('useChatSend attachment payloads', () => { validateMessageEditOwner: messageActions.validateEditOwner, commitMessageEdit: messageActions.commitEdit, adoptRejectedMessageEditRows: messageActions.adoptRejectedEditRows, + ...(validateActiveProjectBeforeSend ? { validateActiveProjectBeforeSend } : {}), }) await harness.api.onSend() @@ -2704,16 +2714,26 @@ describe('useChatSend attachment payloads', () => { if (delayRetirement) vi.useFakeTimers() try { const sendNewEdit = harness.api.onSend() - let repeatedSend: Promise | null = null + let repeatedSends: Promise[] = [] if (delayRetirement) { await vi.waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(2)) - repeatedSend = harness.api.onSend() + 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, repeatedSend]) + await Promise.all([sendNewEdit, ...repeatedSends]) } finally { if (delayRetirement) vi.useRealTimers() } @@ -3579,6 +3599,7 @@ describe('useChatSend attachment payloads', () => { _clientMessageId: string, _taskId: string, _terminal?: boolean | string, + _allowProjection?: boolean, ) => {} const beginBackgroundReceiptReplay = vi.fn((id: string, holdHistory?: boolean) => { beginReplay(id, holdHistory) @@ -3590,12 +3611,14 @@ describe('useChatSend attachment payloads', () => { id: string, taskId: string, terminal?: boolean | string, + allowProjection?: boolean, ) => { - trackReplay(id, taskId, terminal) + trackReplay(id, taskId, terminal, allowProjection) }) 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 @@ -3611,6 +3634,7 @@ describe('useChatSend attachment payloads', () => { finishBackgroundReceiptReplay, trackBackgroundReceiptTask, scheduleHistorySync, + schedulePendingDrainAfterTerminal: scheduleRecoveredQueueDrain, recoverPendingQueueHandoff, messageEditActive: ref(true), prepareAttachmentsForSend: vi.fn(async ({ attachments }) => { @@ -3682,6 +3706,26 @@ describe('useChatSend attachment payloads', () => { 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, + }) + expect(activeStreamTaskId.value).toBe('') + expect(taskOwnership.runningTaskId.value).toBe('recovered-task') resolveRecovery({ sessionKey: child, task_id: 'recovered-task', @@ -3691,19 +3735,13 @@ describe('useChatSend attachment payloads', () => { expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( clientMessageId, 'recovered-task', + 'failed', false, ) expect(applySessionRunState).not.toHaveBeenCalled() expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() + expect(scheduleRecoveredQueueDrain).toHaveBeenCalledOnce() expect(finishBackgroundReceiptReplay).not.toHaveBeenCalled() - 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', @@ -3714,6 +3752,10 @@ describe('useChatSend attachment payloads', () => { stream_seq: 1, text: 'old recovered output', }) + deliver('task.succeeded', { + session_key: parent, + task_id: 'recovered-task', + }) rpcEvents.onConversationEvent({ kind: 'sessions-changed', payload: { @@ -3728,6 +3770,7 @@ describe('useChatSend attachment payloads', () => { 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() diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 5f75f28170..abd2461276 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -204,6 +204,7 @@ interface SendAttempt { /** A background receipt was rejected and only its durable retirement remains. */ backgroundRejectionPending?: boolean backgroundRejectionError?: unknown + backgroundRejectionContinuationClaimed?: boolean acceptedTaskId?: string acceptedSessionKey?: string stopAbortPromise?: Promise | null @@ -645,6 +646,7 @@ export interface UseChatSendOptions { clientMessageId: string, taskId: string, terminal?: boolean | string, + allowProjection?: boolean, ) => void /** Release the pre-response event quarantine for an older receipt replay. */ finishBackgroundReceiptReplay?: (clientMessageId: string) => void @@ -1218,6 +1220,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, @@ -2479,13 +2487,15 @@ export function useChatSend(options: UseChatSendOptions) { }) const targetSessionKey = response.sessionKey || replayRecord.requestSessionKey if (replayRecord.backgroundOnly) { + const allowTerminalProjection = ( + targetSessionKey === replayRecord.requestSessionKey + && targetSessionKey === options.sessionKey.value + ) options.trackBackgroundReceiptTask?.( replayRecord.clientMessageId, acceptedTaskId(response), - targetSessionKey === replayRecord.requestSessionKey - && targetSessionKey === options.sessionKey.value - ? terminalResponseStatus(response) - : false, + terminalResponseStatus(response), + allowTerminalProjection, ) await finalizeRecoveredBackgroundHandoff(replayRecord, targetSessionKey) } else { @@ -3014,6 +3024,38 @@ 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 } @@ -3037,10 +3079,10 @@ export function useChatSend(options: UseChatSendOptions) { && !validateAttemptMessageEditTranscript(exactReplayAttempt) ) return if (replayBlockedReason?.value) return - const rejectionAlreadyPending = exactReplayAttempt.backgroundRejectionPending === true let rejectedReplayRetired = false let rejectedReplayRetirement: Promise | null = null - const dispatchExactReplay = () => dispatchSend(exactReplayAttempt.text, { + let waitedForRejectionRetirement = false + const replayOutcome = await dispatchSend(exactReplayAttempt.text, { composerText, composerSnapshot: replayComposerSnapshot, promptAnnotationIds: exactReplayAttempt.promptAnnotationIds, @@ -3086,24 +3128,21 @@ export function useChatSend(options: UseChatSendOptions) { ) ), }) - const replayOutcome = rejectionAlreadyPending ? null : await dispatchExactReplay() - if (rejectionAlreadyPending) { - rejectedReplayRetirement = scheduleAcceptanceRecovery(exactReplayAttempt) - if (rejectedReplayRetirement) await rejectedReplayRetirement - if ( - exactReplayAttempt.backgroundRejectionPending - || !exactReplayAttempt.acceptanceResolved - ) return - } else if (replayOutcome !== 'accepted' && !rejectedReplayRetired) { + if (!replayingSupersededEditOwner) return + if (replayOutcome !== 'accepted' && !rejectedReplayRetired) { if (!rejectedReplayRetirement) return await rejectedReplayRetirement + waitedForRejectionRetirement = true } - if (!replayingSupersededEditOwner) return if ( options.sessionKey.value !== requestSessionKey || !sameComposerOwnershipSnapshot(captureComposerSnapshot(), replayComposerSnapshot) || !messageEditOwnerMatchesSnapshot(replayComposerSnapshot, true) ) return + if ( + waitedForRejectionRetirement + && !claimBackgroundRejectionContinuation(exactReplayAttempt) + ) return } if (hasPayload) { diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index c3e39cca74..292c132ecb 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1702,6 +1702,7 @@ let trackBackgroundReceiptTask = ( _clientMessageId: string, _taskId: string, _terminal: boolean | string = false, + _allowProjection = true, ) => {} let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} let holdBackgroundReceiptReconciliation = () => {} @@ -3472,8 +3473,8 @@ const chatSend = useChatSend({ beginBackgroundReceiptReplay: (clientMessageId, holdHistory) => ( beginBackgroundReceiptReplay(clientMessageId, holdHistory) ), - trackBackgroundReceiptTask: (clientMessageId, taskId, terminal) => ( - trackBackgroundReceiptTask(clientMessageId, taskId, terminal) + trackBackgroundReceiptTask: (clientMessageId, taskId, terminal, allowProjection) => ( + trackBackgroundReceiptTask(clientMessageId, taskId, terminal, allowProjection) ), finishBackgroundReceiptReplay: clientMessageId => ( finishBackgroundReceiptReplay(clientMessageId) From 1aa1b04260d1780ca28041909924d6fcad3c840f Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 12:05:34 +0800 Subject: [PATCH 23/24] Defer receipt projection until ACK --- .../chat/useChatRpcEventHandlers.test.ts | 78 ++++++++++++- .../chat/useChatRpcEventHandlers.ts | 108 +++++++++++++----- .../chat/useChatSend.attachments.test.ts | 32 +++++- .../src/composables/chat/useChatSend.ts | 11 +- opensquilla-webui/src/views/ChatView.vue | 17 ++- 5 files changed, 208 insertions(+), 38 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index f659aa4865..141a114c86 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -267,6 +267,64 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { } }) + 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('quarantines live and terminal events from a background receipt replay', () => { const harness = createHarness({ messages: [ @@ -535,7 +593,7 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { task_id: 'task-old-receipt', client_message_id: 'client-old-receipt', }) - expect(taskOwnership.hasAuthoritativeWork.value).toBe(true) + expect(taskOwnership.hasAuthoritativeWork.value).toBe(false) harness.api.trackBackgroundReceiptTask( 'client-old-receipt', @@ -599,6 +657,12 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { }, meta: {}, }) + harness.api.trackBackgroundReceiptTask( + 'client-old-receipt', + 'task-old-receipt', + false, + true, + ) expect(harness.applySessionRunState).not.toHaveBeenCalled() expect(harness.clearPendingRouterDecision).not.toHaveBeenCalled() @@ -641,6 +705,12 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { }, meta: {}, }) + harness.api.trackBackgroundReceiptTask( + 'client-terminal-kind', + 'task-terminal-kind', + false, + true, + ) expect(harness.applySessionRunState).toHaveBeenCalledWith(expect.objectContaining({ run_status: expectedRunStatus, @@ -689,6 +759,12 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { }, meta: {}, }) + harness.api.trackBackgroundReceiptTask( + 'client-killed-receipt', + 'task-killed-receipt', + false, + true, + ) expect(harness.applySessionRunState).toHaveBeenCalledWith(expect.objectContaining({ run_status: 'cancelled', diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index 9c58c1f2f4..83aaa00444 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -418,9 +418,21 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) allowProjection: boolean } + interface DeferredBackgroundReceiptTerminal { + clientMessageId: string + eventKind: ConversationSemanticEventKind | 'sessions-changed' + payload: SessionEventPayload + status: string + terminalTask: object + } + 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() @@ -481,7 +493,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) clientMessageId: string, taskId: string, terminalSeen = false, - allowProjection = true, + allowProjection?: boolean, ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() @@ -490,12 +502,15 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) const existing = backgroundReceiptTasks.get(normalizedTaskId) if (!existing && backgroundReceiptTasks.size >= 256) { const oldestTaskId = backgroundReceiptTasks.keys().next().value - if (typeof oldestTaskId === 'string') backgroundReceiptTasks.delete(oldestTaskId) + 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, + allowProjection: allowProjection ?? existing?.allowProjection ?? false, }) } @@ -504,6 +519,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) taskId: string, terminal: boolean | string = false, allowProjection = true, + retireParentProjection = false, ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() @@ -517,8 +533,17 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) Boolean(terminalStatus), allowProjection, ) + const deferredCandidate = normalizedTaskId + ? deferredBackgroundReceiptTerminals.get(normalizedTaskId) + : undefined + const deferredTerminal = deferredCandidate?.clientMessageId === normalizedClientId + ? deferredCandidate + : undefined + if (normalizedTaskId && (terminalStatus || retireParentProjection)) { + options.taskOwnership?.noteTerminal(normalizedTaskId, allowProjection) + } if (terminalStatus) { - if (normalizedTaskId) options.taskOwnership?.noteTerminal(normalizedTaskId) + deferredBackgroundReceiptTerminals.delete(normalizedTaskId) if ( allowProjection && !reconciledBackgroundReceiptClientIds.has(normalizedClientId) @@ -533,6 +558,25 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) {}, allowProjection, ) + } else if (allowProjection && 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, + ) + } else if (retireParentProjection) { + deferredBackgroundReceiptTerminals.delete(normalizedTaskId) } } @@ -624,7 +668,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) if ( identity.clientMessageId && backgroundReceiptClientIds.has(identity.clientMessageId) - ) return { ...identity, allowProjection: true } + ) return { ...identity, allowProjection: false } } return null } @@ -671,28 +715,11 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) // 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') { + if (eventKind === 'task-queued' && allowProjection) { options.taskOwnership?.noteQueued({ ...payload, status: 'queued' }) - } else if (eventKind === 'task-running') { + } else if (eventKind === 'task-running' && allowProjection) { options.taskOwnership?.noteRunning({ ...payload, status: 'running' }) - } else if (terminalEvent) { - options.taskOwnership?.noteTerminal(taskId) - markTaskSettled(payload) - rememberBackgroundReceiptTask(owner, taskId, true, allowProjection) - if ( - allowProjection - && !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) if (terminalEvent) { const terminalTask = terminalSessionChangeTask(payload) const rawStatus = String( @@ -703,14 +730,38 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) || payload.runStatus || '', ).trim().toLowerCase() + const status = rawStatus + || (eventKind === 'sessions-changed' ? '' : eventTaskTerminalStatus(eventKind)) + || (eventKind === 'turn-failed' ? 'failed' : 'succeeded') + rememberBackgroundReceiptTask(owner, taskId, true) + if (!allowProjection) { + deferredBackgroundReceiptTerminals.set(taskId, { + clientMessageId: owner, + eventKind, + payload, + status, + terminalTask: terminalTask || payload, + }) + 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, - rawStatus - || (eventKind === 'sessions-changed' ? '' : eventTaskTerminalStatus(eventKind)) - || (eventKind === 'turn-failed' ? 'failed' : 'succeeded'), + status, terminalTask || payload, - !hasContinuation && allowProjection, + !hasContinuation, ) } return true @@ -1664,6 +1715,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) pendingBackgroundReceiptClientIds.clear() backgroundReceiptClientIds.clear() backgroundReceiptTasks.clear() + deferredBackgroundReceiptTerminals.clear() dirtyBackgroundReceiptClientIds.clear() reconciledBackgroundReceiptClientIds.clear() settledBackgroundReceiptClientIds.clear() diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 339c4178c0..b5423041a5 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3526,7 +3526,7 @@ describe('useChatSend attachment payloads', () => { expect(await pendingInputWal.listHandoffs?.()).toEqual([]) }) - it('quarantines task, turn, and session events during crashed background receipt recovery', async () => { + async function verifyCrashedChildReceiptRecovery(taskStatus: string) { const parent = 'agent:main:webchat:background-recovery-parent' const child = 'agent:main:webchat:background-recovery-child' const ownerRequestId = 'background-recovery-request' @@ -3600,6 +3600,7 @@ describe('useChatSend attachment payloads', () => { _taskId: string, _terminal?: boolean | string, _allowProjection?: boolean, + _retireParentProjection?: boolean, ) => {} const beginBackgroundReceiptReplay = vi.fn((id: string, holdHistory?: boolean) => { beginReplay(id, holdHistory) @@ -3612,8 +3613,9 @@ describe('useChatSend attachment payloads', () => { taskId: string, terminal?: boolean | string, allowProjection?: boolean, + retireParentProjection?: boolean, ) => { - trackReplay(id, taskId, terminal, allowProjection) + trackReplay(id, taskId, terminal, allowProjection, retireParentProjection) }) const applySessionRunState = vi.fn() const scheduleHistorySync = vi.fn() @@ -3724,19 +3726,27 @@ describe('useChatSend attachment payloads', () => { task_id: 'recovered-task', client_message_id: clientMessageId, }) + deliver('task.succeeded', { + session_key: parent, + task_id: 'recovered-task', + client_message_id: clientMessageId, + }) expect(activeStreamTaskId.value).toBe('') - expect(taskOwnership.runningTaskId.value).toBe('recovered-task') + expect(taskOwnership.runningTaskId.value).toBe('') + expect(applySessionRunState).not.toHaveBeenCalled() + expect(scheduleHistorySync).not.toHaveBeenCalled() resolveRecovery({ sessionKey: child, task_id: 'recovered-task', - task_status: 'failed', + ...(taskStatus ? { task_status: taskStatus } : {}), }) await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) expect(trackBackgroundReceiptTask).toHaveBeenCalledWith( clientMessageId, 'recovered-task', - 'failed', + taskStatus, false, + true, ) expect(applySessionRunState).not.toHaveBeenCalled() expect(schedulePendingDrainAfterTerminal).not.toHaveBeenCalled() @@ -3786,7 +3796,17 @@ describe('useChatSend attachment payloads', () => { } 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 verifyCrashedChildReceiptRecovery(taskStatus) + }, + ) it('releases a crashed accepted background-only owner back to the parent drain', async () => { const parent = 'agent:main:webchat:accepted-background-parent' diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index abd2461276..477c881915 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -647,6 +647,7 @@ export interface UseChatSendOptions { taskId: string, terminal?: boolean | string, allowProjection?: boolean, + retireParentProjection?: boolean, ) => void /** Release the pre-response event quarantine for an older receipt replay. */ finishBackgroundReceiptReplay?: (clientMessageId: string) => void @@ -2487,15 +2488,23 @@ export function useChatSend(options: UseChatSendOptions) { }) const targetSessionKey = response.sessionKey || replayRecord.requestSessionKey if (replayRecord.backgroundOnly) { + const requestSessionIsCurrent = ( + replayRecord.requestSessionKey === options.sessionKey.value + ) const allowTerminalProjection = ( targetSessionKey === replayRecord.requestSessionKey - && targetSessionKey === options.sessionKey.value + && requestSessionIsCurrent + ) + const retireParentProjection = ( + requestSessionIsCurrent + && targetSessionKey !== replayRecord.requestSessionKey ) options.trackBackgroundReceiptTask?.( replayRecord.clientMessageId, acceptedTaskId(response), terminalResponseStatus(response), allowTerminalProjection, + retireParentProjection, ) await finalizeRecoveredBackgroundHandoff(replayRecord, targetSessionKey) } else { diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 292c132ecb..b16b9f5532 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1703,6 +1703,7 @@ let trackBackgroundReceiptTask = ( _taskId: string, _terminal: boolean | string = false, _allowProjection = true, + _retireParentProjection = false, ) => {} let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} let holdBackgroundReceiptReconciliation = () => {} @@ -3473,8 +3474,20 @@ const chatSend = useChatSend({ beginBackgroundReceiptReplay: (clientMessageId, holdHistory) => ( beginBackgroundReceiptReplay(clientMessageId, holdHistory) ), - trackBackgroundReceiptTask: (clientMessageId, taskId, terminal, allowProjection) => ( - trackBackgroundReceiptTask(clientMessageId, taskId, terminal, allowProjection) + trackBackgroundReceiptTask: ( + clientMessageId, + taskId, + terminal, + allowProjection, + retireParentProjection, + ) => ( + trackBackgroundReceiptTask( + clientMessageId, + taskId, + terminal, + allowProjection, + retireParentProjection, + ) ), finishBackgroundReceiptReplay: clientMessageId => ( finishBackgroundReceiptReplay(clientMessageId) From bb80dfb8d3cb9c9a4d23e12be03dd54344bc986e Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Thu, 3 Sep 2026 12:22:36 +0800 Subject: [PATCH 24/24] Harden accepted receipt recovery --- .../chat/useChatRpcEventHandlers.test.ts | 80 +++++- .../chat/useChatRpcEventHandlers.ts | 126 ++++++--- .../chat/useChatSend.attachments.test.ts | 248 ++++++++++++++---- .../src/composables/chat/useChatSend.ts | 173 +++++++++--- .../src/utils/chat/pendingInputWal.ts | 12 + opensquilla-webui/src/views/ChatView.vue | 3 + 6 files changed, 519 insertions(+), 123 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts index 141a114c86..bc509df1ef 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.test.ts @@ -325,6 +325,77 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { } }) + 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: [ @@ -460,7 +531,14 @@ describe('useChatRpcEventHandlers decoded conversation ingress', () => { expect(harness.stream.startStreaming).not.toHaveBeenCalled() expect(harness.stream.appendDelta).not.toHaveBeenCalled() expect(harness.stream.endStreaming).not.toHaveBeenCalled() - expect(harness.applySessionRunState).toHaveBeenCalledTimes(2) + 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' }), diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index 83aaa00444..5a40e16868 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -416,6 +416,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) clientMessageId: string terminalSeen: boolean allowProjection: boolean + lifecycleStatus: string } interface DeferredBackgroundReceiptTerminal { @@ -424,6 +425,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) payload: SessionEventPayload status: string terminalTask: object + priority: number } const pendingBackgroundReceiptClientIds = new Set() @@ -494,6 +496,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) taskId: string, terminalSeen = false, allowProjection?: boolean, + lifecycleStatus = '', ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() @@ -511,6 +514,12 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) 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 || '', }) } @@ -520,6 +529,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) terminal: boolean | string = false, allowProjection = true, retireParentProjection = false, + acceptedStatus = '', ) { const normalizedClientId = String(clientMessageId || '').trim() const normalizedTaskId = String(taskId || '').trim() @@ -539,26 +549,23 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) const deferredTerminal = deferredCandidate?.clientMessageId === normalizedClientId ? deferredCandidate : undefined - if (normalizedTaskId && (terminalStatus || retireParentProjection)) { - options.taskOwnership?.noteTerminal(normalizedTaskId, allowProjection) - } - if (terminalStatus) { + if (!allowProjection) { + if (normalizedTaskId && (terminalStatus || retireParentProjection)) { + options.taskOwnership?.noteTerminal(normalizedTaskId, false) + } deferredBackgroundReceiptTerminals.delete(normalizedTaskId) - if ( - allowProjection - && !reconciledBackgroundReceiptClientIds.has(normalizedClientId) - ) { - dirtyBackgroundReceiptClientIds.add(normalizedClientId) - flushBackgroundReceiptReconciliationIfReady() + if (terminalStatus) { + settleBackgroundReceiptTerminal( + normalizedClientId, + normalizedTaskId, + terminalStatus, + {}, + false, + ) } - settleBackgroundReceiptTerminal( - normalizedClientId, - normalizedTaskId, - terminalStatus, - {}, - allowProjection, - ) - } else if (allowProjection && deferredTerminal) { + return + } + if (deferredTerminal) { deferredBackgroundReceiptTerminals.delete(normalizedTaskId) options.taskOwnership?.noteTerminal(normalizedTaskId) markTaskSettled(deferredTerminal.payload) @@ -575,8 +582,43 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) deferredTerminal.terminalTask, !hasContinuation, ) - } else if (retireParentProjection) { + 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 }, + }) + } } } @@ -699,6 +741,15 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) 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, @@ -715,10 +766,16 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) // 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' && allowProjection) { - options.taskOwnership?.noteQueued({ ...payload, status: 'queued' }) - } else if (eventKind === 'task-running' && allowProjection) { - options.taskOwnership?.noteRunning({ ...payload, status: 'running' }) + 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) @@ -735,13 +792,22 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) || (eventKind === 'turn-failed' ? 'failed' : 'succeeded') rememberBackgroundReceiptTask(owner, taskId, true) if (!allowProjection) { - deferredBackgroundReceiptTerminals.set(taskId, { - clientMessageId: owner, - eventKind, - payload, - status, - terminalTask: terminalTask || payload, - }) + 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) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index b5423041a5..0350a4aa44 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3526,7 +3526,7 @@ describe('useChatSend attachment payloads', () => { expect(await pendingInputWal.listHandoffs?.()).toEqual([]) }) - async function verifyCrashedChildReceiptRecovery(taskStatus: string) { + 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' @@ -3601,6 +3601,7 @@ describe('useChatSend attachment payloads', () => { _terminal?: boolean | string, _allowProjection?: boolean, _retireParentProjection?: boolean, + _acceptedStatus?: string, ) => {} const beginBackgroundReceiptReplay = vi.fn((id: string, holdHistory?: boolean) => { beginReplay(id, holdHistory) @@ -3614,8 +3615,16 @@ describe('useChatSend attachment payloads', () => { terminal?: boolean | string, allowProjection?: boolean, retireParentProjection?: boolean, + acceptedStatus?: string, ) => { - trackReplay(id, taskId, terminal, allowProjection, retireParentProjection) + trackReplay( + id, + taskId, + terminal, + allowProjection, + retireParentProjection, + acceptedStatus, + ) }) const applySessionRunState = vi.fn() const scheduleHistorySync = vi.fn() @@ -3726,17 +3735,19 @@ describe('useChatSend attachment payloads', () => { task_id: 'recovered-task', client_message_id: clientMessageId, }) - deliver('task.succeeded', { - 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: child, + sessionKey: sameSession ? parent : child, task_id: 'recovered-task', ...(taskStatus ? { task_status: taskStatus } : {}), }) @@ -3745,9 +3756,54 @@ describe('useChatSend attachment payloads', () => { clientMessageId, 'recovered-task', taskStatus, - false, - true, + 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() @@ -3804,56 +3860,94 @@ describe('useChatSend attachment payloads', () => { ] as const)( 'quarantines task, turn, and session events after a crashed %s child ACK', async (_label, taskStatus) => { - await verifyCrashedChildReceiptRecovery(taskStatus) + await verifyCrashedReceiptRecovery(taskStatus) }, ) - it('releases a crashed accepted background-only owner back to the parent drain', async () => { - const parent = 'agent:main:webchat:accepted-background-parent' - const child = 'agent:main:webchat:accepted-background-child' - const ownerRequestId = 'accepted-background-request' - const pendingInputWal = memoryHandoffWal() - await pendingInputWal.prepareHandoff!({ - schemaVersion: 1, - ownerRequestId, - requestSessionKey: parent, - clientRequestId: ownerRequestId, - clientMessageId: 'accepted-background-message', - composerText: 'already accepted offscreen', - recoveryAttachments: [], - params: { - sessionKey: parent, + 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: 'accepted-background-message', - message: 'already accepted offscreen', - forkBeforeMessageId: 'fork-anchor', - }, - backgroundOnly: true, - acceptedSessionKey: child, - walOwnerId: 'accepted-background-owner', - walRevision: 4, - state: 'accepted', - createdAt: 1, - updatedAt: 2, - }) - const adoptResponseSession = vi.fn() - const recoverPendingQueueHandoff = vi.fn(async () => true) - const harness = makeOptions({ - sessionKey: ref(parent), - pendingInputWal, - adoptResponseSession, - recoverPendingQueueHandoff, - hasPendingQueueWork: () => true, - }) + 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, + }) - await harness.api.recoverResponseHandoffs() + const recovery = harness.api.recoverResponseHandoffs() + await vi.waitFor(() => expect(recoverPendingQueueHandoff).toHaveBeenCalledOnce()) - expect(harness.rpc.call).not.toHaveBeenCalled() - expect(adoptResponseSession).not.toHaveBeenCalled() - expect(recoverPendingQueueHandoff).toHaveBeenCalledWith(parent, parent, ownerRequestId) - expect(harness.options.flushDeferredPendingDrain).toHaveBeenCalledOnce() - expect(harness.options.schedulePendingDrainAfterTerminal).toHaveBeenCalledOnce() - expect(await pendingInputWal.listHandoffs!()).toEqual([]) + 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( @@ -4670,6 +4764,7 @@ describe('useChatSend attachment payloads', () => { clientMessageId, expectedTaskId, expectedStatus, + ...(expectedTaskId ? [true, false, expectedStatus] : []), ) expect(inputText.value).toBe('newer question') }, @@ -4712,10 +4807,53 @@ describe('useChatSend attachment payloads', () => { 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).not.toHaveBeenCalled() + 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, diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 477c881915..c6595fd52c 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -206,6 +206,7 @@ interface SendAttempt { backgroundRejectionError?: unknown backgroundRejectionContinuationClaimed?: boolean acceptedTaskId?: string + acceptedTaskStatus?: string acceptedSessionKey?: string stopAbortPromise?: Promise | null autoRecoverAcceptance?: boolean @@ -290,6 +291,7 @@ interface ResponseHandoffGate { targetSessionKey: string | null stoppedByUser: boolean acceptedTaskId: string + acceptedTaskStatus: string terminalResponse: boolean authoritativeIdle: boolean backgroundOnly: boolean @@ -648,6 +650,7 @@ export interface UseChatSendOptions { 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 @@ -1154,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() @@ -1366,6 +1394,7 @@ export function useChatSend(options: UseChatSendOptions) { acknowledgeAttemptPromptAnnotations(attempt, response, responseOwnsVisibleTranscript) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(response) + attempt.acceptedTaskStatus = taskAcceptanceStatus(response) attempt.acceptedSessionKey = acceptedSessionKey if ( responseOwnsVisibleTranscript @@ -1396,13 +1425,13 @@ export function useChatSend(options: UseChatSendOptions) { options.scheduleHistorySync() } else if (isCurrentRequest) { consumeAcceptedSessionIntent(attempt) - if (acceptedSessionKey === attempt.requestSessionKey) { - options.trackBackgroundReceiptTask?.( - attempt.clientMessageId, - accepted.taskId, - terminalStatus, - ) - } + } + if (!responseOwnsVisibleTranscript) { + trackBackgroundReceiptResponse( + attempt.clientMessageId, + response, + attempt.requestSessionKey, + ) } if ( !responseOwnsVisibleTranscript @@ -1465,6 +1494,7 @@ export function useChatSend(options: UseChatSendOptions) { targetSessionKey: null, stoppedByUser: attempt.stopRequested === true, acceptedTaskId: attempt.acceptedTaskId || '', + acceptedTaskStatus: attempt.acceptedTaskStatus || '', terminalResponse: false, authoritativeIdle: false, backgroundOnly: true, @@ -1637,6 +1667,7 @@ export function useChatSend(options: UseChatSendOptions) { targetSessionKey: null, stoppedByUser: false, acceptedTaskId: '', + acceptedTaskStatus: '', terminalResponse: false, authoritativeIdle: false, backgroundOnly: false, @@ -1896,11 +1927,15 @@ export function useChatSend(options: UseChatSendOptions) { 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(), @@ -2004,6 +2039,7 @@ export function useChatSend(options: UseChatSendOptions) { targetSessionKey: acceptedSessionKey, stoppedByUser: attempt.stopRequested === true, acceptedTaskId: attempt.acceptedTaskId || '', + acceptedTaskStatus: attempt.acceptedTaskStatus || '', terminalResponse: false, authoritativeIdle: false, backgroundOnly: true, @@ -2111,6 +2147,7 @@ export function useChatSend(options: UseChatSendOptions) { targetSessionKey: null, stoppedByUser: attempt.stopRequested === true, acceptedTaskId: attempt.acceptedTaskId || '', + acceptedTaskStatus: attempt.acceptedTaskStatus || '', terminalResponse: false, authoritativeIdle: false, backgroundOnly: true, @@ -2308,20 +2345,73 @@ export function useChatSend(options: UseChatSendOptions) { async function finalizeRecoveredBackgroundHandoff( record: ResponseHandoffWalRecord, targetSessionKey: string, - ): Promise { + 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 { - await finalizeBackgroundResponseHandoff(gate, targetSessionKey) + 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, @@ -2465,7 +2555,25 @@ export function useChatSend(options: UseChatSendOptions) { } if (record.state === 'accepted' && record.acceptedSessionKey) { if (record.backgroundOnly) { - await finalizeRecoveredBackgroundHandoff(record, record.acceptedSessionKey) + 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) } @@ -2488,25 +2596,17 @@ export function useChatSend(options: UseChatSendOptions) { }) const targetSessionKey = response.sessionKey || replayRecord.requestSessionKey if (replayRecord.backgroundOnly) { - const requestSessionIsCurrent = ( - replayRecord.requestSessionKey === options.sessionKey.value - ) - const allowTerminalProjection = ( - targetSessionKey === replayRecord.requestSessionKey - && requestSessionIsCurrent - ) - const retireParentProjection = ( - requestSessionIsCurrent - && targetSessionKey !== replayRecord.requestSessionKey - ) - options.trackBackgroundReceiptTask?.( + trackBackgroundReceiptResponse( replayRecord.clientMessageId, + response, + replayRecord.requestSessionKey, + ) + await finalizeRecoveredBackgroundHandoffUntilComplete( + replayRecord, + targetSessionKey, acceptedTaskId(response), - terminalResponseStatus(response), - allowTerminalProjection, - retireParentProjection, + taskAcceptanceStatus(response), ) - await finalizeRecoveredBackgroundHandoff(replayRecord, targetSessionKey) } else { await finalizeRecoveredHandoff(replayRecord, targetSessionKey) } @@ -2515,7 +2615,10 @@ export function useChatSend(options: UseChatSendOptions) { const accepted = acceptedErrorInfo(error) if (accepted?.sessionKey) { if (replayRecord.backgroundOnly) { - await finalizeRecoveredBackgroundHandoff(replayRecord, accepted.sessionKey) + await finalizeRecoveredBackgroundHandoffUntilComplete( + replayRecord, + accepted.sessionKey, + ) } else { await finalizeRecoveredHandoff(replayRecord, accepted.sessionKey) } @@ -4099,6 +4202,7 @@ export function useChatSend(options: UseChatSendOptions) { acknowledgeAttemptPromptAnnotations(attempt, res, responseOwnsVisibleTranscript) attempt.acceptanceResolved = true attempt.acceptedTaskId = acceptedTaskId(res) + attempt.acceptedTaskStatus = taskAcceptanceStatus(res) attempt.acceptedSessionKey = res?.sessionKey || requestSessionKey if ( responseOwnsVisibleTranscript @@ -4118,18 +4222,10 @@ export function useChatSend(options: UseChatSendOptions) { if (acceptedSessionKey === requestSessionKey) { noteAcceptedTask(res, requestSessionKey) } - if ( - options.sessionKey.value === requestSessionKey - && acceptedSessionKey === requestSessionKey - ) { - options.trackBackgroundReceiptTask?.( - attempt.clientMessageId, - taskId, - terminalStatus, - ) - } + trackBackgroundReceiptResponse(attempt.clientMessageId, res, requestSessionKey) if (responseHandoff) { responseHandoff.acceptedTaskId = taskId + responseHandoff.acceptedTaskStatus = taskAcceptanceStatus(res) responseHandoff.terminalResponse = Boolean(terminalStatus) const finalized = await finalizeBackgroundResponseHandoff( responseHandoff, @@ -4177,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 @@ -4238,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) { @@ -4282,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') { diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index 7b26f34d1c..ce7d1bbdee 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -62,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 @@ -225,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' || ( diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index b16b9f5532..74e25f26d1 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1704,6 +1704,7 @@ let trackBackgroundReceiptTask = ( _terminal: boolean | string = false, _allowProjection = true, _retireParentProjection = false, + _acceptedStatus = '', ) => {} let finishBackgroundReceiptReplay = (_clientMessageId: string) => {} let holdBackgroundReceiptReconciliation = () => {} @@ -3480,6 +3481,7 @@ const chatSend = useChatSend({ terminal, allowProjection, retireParentProjection, + acceptedStatus, ) => ( trackBackgroundReceiptTask( clientMessageId, @@ -3487,6 +3489,7 @@ const chatSend = useChatSend({ terminal, allowProjection, retireParentProjection, + acceptedStatus, ) ), finishBackgroundReceiptReplay: clientMessageId => (