From 44f9a00c01d2593d8d7d4de049b66907a7c28406 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Tue, 1 Sep 2026 23:19:47 +0800 Subject: [PATCH 01/18] Scope pending attachments to committed sessions --- .../composables/chat/useChatAttachments.ts | 56 ++++++++++++++++--- .../composables/chat/useChatSessionRuntime.ts | 3 + opensquilla-webui/src/views/ChatView.vue | 3 +- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatAttachments.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.ts index b174b7a02..c2d15b0e7 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.ts @@ -41,6 +41,7 @@ type AttachmentPreparationOptions = { // Per-addAttachments-call state so batch-wide rejections (the aggregate size // cap) toast once instead of once per rejected file. type AttachmentBatch = { + generation: number totalSizeToastShown: boolean } @@ -117,6 +118,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { const nextAttachmentId = ref(1) const refreshInFlightAttachmentIds = new Set() const refreshInFlightAttachmentCount = ref(0) + let attachmentGeneration = 0 const attachmentWorkBusy = computed(() => refreshInFlightAttachmentCount.value > 0 || pendingAttachments.value.some( @@ -133,8 +135,12 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } async function addAttachments(files: File[]) { - const batch: AttachmentBatch = { totalSizeToastShown: false } + const batch: AttachmentBatch = { + generation: attachmentGeneration, + totalSizeToastShown: false, + } for (const file of files) { + if (!isAttachmentGenerationCurrent(batch.generation)) return // One toast for the whole batch when the count cap is hit — a per-file // repeat would only evict more useful toasts. if (activeAttachmentCount() >= MAX_ATTACHMENTS) { @@ -142,6 +148,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { return } await addAttachmentFile(file, batch) + if (!isAttachmentGenerationCurrent(batch.generation)) return } } @@ -150,6 +157,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } async function addAttachmentFile(file: File, batch: AttachmentBatch) { + if (!isAttachmentGenerationCurrent(batch.generation)) return const fileName = file.name || 'Untitled file' if (file.size === 0) { pushToast(i18n.global.t('chat.toast.emptyFile', { name: fileName }), { tone: 'danger' }) @@ -158,7 +166,9 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { let mime = resolveAttachmentMime(file) if (!isAllowedAttachmentMime(mime)) { - if (await fileLooksLikeUtf8Text(file)) { + const looksLikeText = await fileLooksLikeUtf8Text(file) + if (!isAttachmentGenerationCurrent(batch.generation)) return + if (looksLikeText) { // Unknown-but-textual uploads degrade to text/plain so the gateway's // UTF-8 fallback is reachable from the WebUI (the gateway re-validates). mime = 'text/plain' @@ -179,6 +189,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { pendingAttachments.value.push({ kind: 'inline_pending', local_id: localId, name: fileName, mime, size: file.size, file }) const reader = new FileReader() reader.onload = (e) => { + if (!isAttachmentGenerationCurrent(batch.generation)) return const dataUrl = e.target?.result as string const b64 = dataUrl?.split(',')[1] || '' const idx = pendingAttachments.value.findIndex(a => a.local_id === localId) @@ -187,6 +198,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } } reader.onerror = () => { + if (!isAttachmentGenerationCurrent(batch.generation)) return const message = i18n.global.t('chat.toast.couldNotReadFile', { name: fileName }) markAttachmentFailed(localId, file, mime, message) pushToast(message, { tone: 'danger' }) @@ -201,15 +213,22 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } pendingAttachments.value.push({ kind: 'uploading', local_id: localId, name: fileName, mime, size: file.size, file }) - uploadAttachmentStaged(file, mime, localId).catch((err) => { + uploadAttachmentStaged(file, mime, localId, batch.generation).catch((err) => { + if (!isAttachmentGenerationCurrent(batch.generation)) return const message = uploadFailureMessage(err) markAttachmentFailed(localId, file, mime, message) pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: fileName })}: ${message}`, { tone: 'danger' }) }) } - async function uploadAttachmentStaged(file: File, mime: string, localId: number) { + async function uploadAttachmentStaged( + file: File, + mime: string, + localId: number, + generation: number, + ) { const meta = await uploadAttachmentFile(file, mime) + if (!isAttachmentGenerationCurrent(generation)) return const idx = pendingAttachments.value.findIndex(a => a.local_id === localId) if (idx >= 0) { pendingAttachments.value[idx] = { @@ -235,6 +254,13 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { pendingAttachments.value.splice(index, 1) } + function retireAttachments() { + attachmentGeneration += 1 + pendingAttachments.value = [] + refreshInFlightAttachmentIds.clear() + refreshInFlightAttachmentCount.value = 0 + } + async function retryAttachment(index: number) { const attachment = pendingAttachments.value[index] if (!attachment || attachment.kind !== 'failed') return @@ -273,10 +299,14 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { async function prepareAttachmentsForSend(options: AttachmentPreparationOptions = {}): Promise { const isCurrent = options.isCurrent ?? (() => true) + const generation = attachmentGeneration + const preparationIsCurrent = () => ( + isAttachmentGenerationCurrent(generation) && isCurrent() + ) const attachments = options.attachments ?? pendingAttachments.value const staged = [...attachments].filter(stagedUploadNeedsRefresh) for (const attachment of staged) { - if (!isCurrent()) return false + if (!preparationIsCurrent()) return false if (refreshInFlightAttachmentIds.has(attachment.local_id)) return false const idx = attachments.findIndex(a => a.local_id === attachment.local_id) if (idx < 0 || attachments[idx].kind !== 'staged') continue @@ -296,7 +326,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { refreshInFlightAttachmentCount.value = refreshInFlightAttachmentIds.size try { const meta = await uploadAttachmentFile(attachment.file, attachment.mime) - if (!isCurrent()) return false + if (!preparationIsCurrent()) return false const currentIdx = attachments.findIndex(a => a.local_id === attachment.local_id) if (currentIdx < 0 || attachments[currentIdx].kind !== 'staged') continue attachments[currentIdx] = { @@ -311,7 +341,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { file: attachment.file, } } catch (err: unknown) { - if (!isCurrent()) return false + if (!preparationIsCurrent()) return false const message = uploadFailureMessage(err) markAttachmentFailed( attachment.local_id, @@ -323,8 +353,10 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: attachment.name })}: ${message}`, { tone: 'danger' }) return false } finally { - refreshInFlightAttachmentIds.delete(attachment.local_id) - refreshInFlightAttachmentCount.value = refreshInFlightAttachmentIds.size + if (isAttachmentGenerationCurrent(generation)) { + refreshInFlightAttachmentIds.delete(attachment.local_id) + refreshInFlightAttachmentCount.value = refreshInFlightAttachmentIds.size + } } } return true @@ -335,6 +367,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } function canAcceptAttachment(fileName: string, size: number, batch: AttachmentBatch): boolean { + if (!isAttachmentGenerationCurrent(batch.generation)) return false const activeAttachments = pendingAttachments.value.filter(attachmentCountsTowardLimits) if (activeAttachments.length >= MAX_ATTACHMENTS) { pushToast(i18n.global.t('chat.toast.tooManyAttachments', { max: MAX_ATTACHMENTS }), { tone: 'danger' }) @@ -356,6 +389,10 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { return true } + function isAttachmentGenerationCurrent(generation: number): boolean { + return generation === attachmentGeneration + } + return { pendingAttachments, attachmentWorkBusy, @@ -363,6 +400,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { addAttachments, addAttachment, removeAttachment, + retireAttachments, retryAttachment, hasPendingAttachmentWork, prepareAttachmentsForSend, diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts index 68605f3cf..ce010167c 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts @@ -78,6 +78,7 @@ export interface UseChatSessionRuntimeOptions { resetSavingsPopupCooldown: () => void restoreWidgetState: () => void resetStreamLiveTurnState: () => void + retireAttachments?: () => void resetDraftComposer?: () => void } @@ -216,6 +217,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { return } + if (pendingQueuePolicy.kind === 'navigate') options.retireAttachments?.() // Commit is deliberately synchronous from the logical cancellation // through the next bootstrap. unsubscribeSession sends its generation- // pinned frame before cancelSessionBootstrap returns, so B never waits for @@ -340,6 +342,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { finishHandoff(epoch, 'superseded') return } + options.retireAttachments?.() options.cancelSessionBootstrap() resetCompactState() options.sessionKey.value = key diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index c421ce773..a2fffa56d 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -1822,6 +1822,7 @@ const { onFileInputChange, addAttachments, removeAttachment, + retireAttachments, retryAttachment, hasPendingAttachmentWork, prepareAttachmentsForSend, @@ -2917,9 +2918,9 @@ const chatSessionRuntime = useChatSessionRuntime({ resetSavingsPopupCooldown, restoreWidgetState, resetStreamLiveTurnState, + retireAttachments, resetDraftComposer: () => { inputText.value = '' - pendingAttachments.value = [] resetComposerInputHistory() autoResizeTextarea() }, From d967fbab4756e8e0518ac493ddc3c8cb50c0ce7d Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Tue, 1 Sep 2026 23:19:51 +0800 Subject: [PATCH 02/18] Test pending attachment session retirement --- .../chat/useChatAttachments.test.ts | 205 ++++++++++++++++++ .../chat/useChatSessionRuntime.draft.test.ts | 3 + .../chat/useChatSessionRuntime.test.ts | 65 ++++++ 3 files changed, 273 insertions(+) diff --git a/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts index c1c90f004..54b9689aa 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts @@ -10,6 +10,37 @@ vi.mock('@/composables/useToasts', () => ({ useToasts: () => ({ pushToast }), })) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +class ControlledFileReader { + static instances: ControlledFileReader[] = [] + + result: string | ArrayBuffer | null = null + onload: ((event: ProgressEvent) => void) | null = null + onerror: ((event: ProgressEvent) => void) | null = null + + readAsDataURL(_blob: Blob) { + ControlledFileReader.instances.push(this) + } + + succeed(dataUrl: string) { + this.result = dataUrl + this.onload?.({ target: this } as unknown as ProgressEvent) + } + + fail() { + this.onerror?.({ target: this } as unknown as ProgressEvent) + } +} + function stagedPdf(name = 'paper.pdf') { return new File([new Uint8Array(2_000_001)], name, { type: 'application/pdf' }) } @@ -35,6 +66,16 @@ function successfulUploadResponse(fileUuid = 'file-1') { } } +function nextSessionAttachment(localId: number): Attachment { + return { + kind: 'inline', + local_id: localId, + name: 'session-b.txt', + mime: 'text/plain', + data: 'Qg==', + } +} + async function flushUpload() { await new Promise(resolve => setTimeout(resolve, 0)) } @@ -98,6 +139,7 @@ function useTestChatAttachments() { describe('useChatAttachments', () => { beforeEach(() => { pushToast.mockClear() + ControlledFileReader.instances = [] vi.stubGlobal('sessionStorage', { getItem: vi.fn((key: string) => key === 'opensquilla.wsToken' ? 'token-123' : null), }) @@ -107,6 +149,169 @@ describe('useChatAttachments', () => { vi.unstubAllGlobals() }) + it.each([ + ['successful', (sniff: ReturnType>) => { + sniff.resolve(new TextEncoder().encode('session A text').buffer) + }], + ['failed', (sniff: ReturnType>) => { + sniff.reject(new Error('sniff failed')) + }], + ])('ignores a delayed %s MIME sniff after attachments retire', async (_outcome, settle) => { + vi.stubGlobal('FileReader', ControlledFileReader) + const attachments = useTestChatAttachments() + const sniff = deferred() + const file = new File(['session A'], 'draft.unknown', { type: 'application/x-unknown' }) + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn(() => sniff.promise), + }) + + const adding = attachments.addAttachment(file) + expect(attachments.pendingAttachments.value).toEqual([]) + + attachments.retireAttachments() + const sessionBAttachment = nextSessionAttachment(1) + attachments.pendingAttachments.value = [sessionBAttachment] + settle(sniff) + await adding + + expect(ControlledFileReader.instances).toHaveLength(0) + expect(attachments.pendingAttachments.value).toEqual([sessionBAttachment]) + expect(pushToast).not.toHaveBeenCalled() + }) + + it.each(['load', 'error'] as const)( + 'ignores a delayed FileReader %s callback after attachments retire', + async outcome => { + vi.stubGlobal('FileReader', ControlledFileReader) + const attachments = useTestChatAttachments() + + await attachments.addAttachment( + new File(['session A'], 'draft.txt', { type: 'text/plain' }), + ) + + expect(ControlledFileReader.instances).toHaveLength(1) + const reader = ControlledFileReader.instances[0]! + const localId = attachments.pendingAttachments.value[0]!.local_id + attachments.retireAttachments() + const sessionBAttachment = nextSessionAttachment(localId) + attachments.pendingAttachments.value = [sessionBAttachment] + + if (outcome === 'load') reader.succeed('data:text/plain;base64,QQ==') + else reader.fail() + + expect(attachments.pendingAttachments.value).toEqual([sessionBAttachment]) + expect(pushToast).not.toHaveBeenCalled() + }, + ) + + it.each(['success', 'failure'] as const)( + 'ignores a delayed staged upload %s after attachments retire', + async outcome => { + const upload = deferred() + vi.stubGlobal('fetch', vi.fn(() => upload.promise)) + const attachments = useTestChatAttachments() + + await attachments.addAttachment(stagedPdf('session-a.pdf')) + + expect(attachments.pendingAttachments.value).toMatchObject([ + { kind: 'uploading', name: 'session-a.pdf' }, + ]) + const localId = attachments.pendingAttachments.value[0]!.local_id + attachments.retireAttachments() + const sessionBAttachment = nextSessionAttachment(localId) + attachments.pendingAttachments.value = [sessionBAttachment] + + if (outcome === 'success') upload.resolve(successfulUploadResponse('file-session-a')) + else upload.reject(new Error('late upload failure')) + await flushUpload() + + expect(attachments.pendingAttachments.value).toEqual([sessionBAttachment]) + expect(pushToast).not.toHaveBeenCalled() + }, + ) + + it.each(['success', 'failure'] as const)( + 'retires delayed staged refresh %s state without touching the next session', + async outcome => { + const upload = deferred() + vi.stubGlobal('fetch', vi.fn(() => upload.promise)) + const attachments = useTestChatAttachments() + const sessionAFile = stagedPdf('session-a-refresh.pdf') + attachments.pendingAttachments.value = [{ + kind: 'staged', + local_id: 1, + name: sessionAFile.name, + mime: sessionAFile.type, + file_uuid: 'file-expired', + expires_at: Date.now() / 1000 - 1, + file: sessionAFile, + }] + + const preparing = attachments.prepareAttachmentsForSend() + expect(attachments.hasPendingAttachmentWork()).toBe(true) + attachments.retireAttachments() + const sessionBAttachment = nextSessionAttachment(1) + attachments.pendingAttachments.value = [sessionBAttachment] + + if (outcome === 'success') upload.resolve(successfulUploadResponse('file-session-a-fresh')) + else upload.reject(new Error('late refresh failure')) + + await expect(preparing).resolves.toBe(false) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + expect(attachments.pendingAttachments.value).toEqual([sessionBAttachment]) + expect(pushToast).not.toHaveBeenCalled() + }, + ) + + it('does not let retired refresh cleanup release same-id work in the next session', async () => { + const sessionAUpload = deferred() + const sessionBUpload = deferred() + vi.stubGlobal('fetch', vi.fn() + .mockImplementationOnce(() => sessionAUpload.promise) + .mockImplementationOnce(() => sessionBUpload.promise)) + const attachments = useTestChatAttachments() + const sessionAFile = stagedPdf('session-a-refresh.pdf') + attachments.pendingAttachments.value = [{ + kind: 'staged', + local_id: 1, + name: sessionAFile.name, + mime: sessionAFile.type, + file_uuid: 'file-a-expired', + expires_at: Date.now() / 1000 - 1, + file: sessionAFile, + }] + + const preparingSessionA = attachments.prepareAttachmentsForSend() + attachments.retireAttachments() + const sessionBFile = stagedPdf('session-b-refresh.pdf') + attachments.pendingAttachments.value = [{ + kind: 'staged', + local_id: 1, + name: sessionBFile.name, + mime: sessionBFile.type, + file_uuid: 'file-b-expired', + expires_at: Date.now() / 1000 - 1, + file: sessionBFile, + }] + const preparingSessionB = attachments.prepareAttachmentsForSend() + + sessionAUpload.resolve(successfulUploadResponse('file-a-fresh')) + await expect(preparingSessionA).resolves.toBe(false) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + expect(attachments.pendingAttachments.value).toMatchObject([ + { name: 'session-b-refresh.pdf', file_uuid: 'file-b-expired' }, + ]) + + sessionBUpload.resolve(successfulUploadResponse('file-b-fresh')) + await expect(preparingSessionB).resolves.toBe(true) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + expect(attachments.pendingAttachments.value).toMatchObject([ + { name: 'session-b-refresh.pdf', file_uuid: 'file-b-fresh' }, + ]) + expect(pushToast).not.toHaveBeenCalled() + }) + it('accepts every file type in a mixed batch (opaque binaries included)', async () => { const fetchMock = vi.fn().mockResolvedValue(successfulUploadResponse('file-valid')) vi.stubGlobal('fetch', fetchMock) diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.draft.test.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.draft.test.ts index 965552562..4e32a620d 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.draft.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.draft.test.ts @@ -9,6 +9,7 @@ describe('useChatSessionRuntime project drafts', () => { it('clears composer state when an explicit new task replaces an empty draft', () => { const sessionKey = ref('agent:main:webchat:project-a-draft') const resetDraftComposer = vi.fn() + const retireAttachments = vi.fn() const taskOwnership = useChatTaskOwnership() taskOwnership.noteRunning('task-project-a') const activeStreamTaskId = ref('task-project-a') @@ -62,6 +63,7 @@ describe('useChatSessionRuntime project drafts', () => { resetSavingsPopupCooldown: vi.fn(), restoreWidgetState: vi.fn(), resetStreamLiveTurnState: vi.fn(), + retireAttachments, resetDraftComposer, }) @@ -69,6 +71,7 @@ describe('useChatSessionRuntime project drafts', () => { expect(sessionKey.value).toBe('agent:main:webchat:project-b-draft') expect(resetDraftComposer).toHaveBeenCalledOnce() + expect(retireAttachments).toHaveBeenCalledOnce() expect(taskOwnership.runningTaskId.value).toBe('') expect(taskOwnership.queuedTaskIds.value.size).toBe(0) expect(taskOwnership.hydrationResolved.value).toBe(true) diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts index e2fa993f2..1579f9a98 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts @@ -23,6 +23,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { const switchPendingQueue = vi.fn() const persistSession = vi.fn((key: string) => { sessionKey.value = key }) const cancelSessionBootstrap = vi.fn() + const retireAttachments = vi.fn() const liveOutcome = { authoritative: true, live: false, @@ -61,6 +62,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { resetSavingsPopupCooldown: vi.fn(), restoreWidgetState: vi.fn(), resetStreamLiveTurnState: vi.fn(), + retireAttachments, }) await expect(runtime.rebindDraftSession( @@ -78,6 +80,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { ) expect(startSessionBootstrap).toHaveBeenCalledWith({ includeHistory: false }) expect(persistSession).not.toHaveBeenCalled() + expect(retireAttachments).not.toHaveBeenCalled() }) it('does not rebind after the draft ownership guard fails', async () => { @@ -126,6 +129,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { const persistSession = vi.fn((key: string) => { sessionKey.value = key }) const setSessionHandoffTarget = vi.fn() const beginSessionResolution = vi.fn() + const retireAttachments = vi.fn() const runtime = useChatSessionRuntime({ sessionKey, messages: ref([]), @@ -164,12 +168,14 @@ describe('useChatSessionRuntime Meta draft recovery', () => { resetSavingsPopupCooldown: vi.fn(), restoreWidgetState: vi.fn(), resetStreamLiveTurnState: vi.fn(), + retireAttachments, }) const switching = runtime.switchToSession('agent:main:webchat:b') expect(sessionKey.value).toBe('agent:main:webchat:a') expect(cancelSessionBootstrap).not.toHaveBeenCalled() expect(beginSessionResolution).not.toHaveBeenCalled() + expect(retireAttachments).not.toHaveBeenCalled() finishQueue() await switching @@ -187,6 +193,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { 1, ) expect(setSessionHandoffTarget).toHaveBeenLastCalledWith(null, 1, 'committed') + expect(retireAttachments).toHaveBeenCalledOnce() }) it('supersedes delayed A to B when navigation returns to A', async () => { @@ -196,6 +203,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { const cancelSessionBootstrap = vi.fn() const persistSession = vi.fn((key: string) => { sessionKey.value = key }) const beginSessionResolution = vi.fn() + const retireAttachments = vi.fn() const switchPendingQueue = vi.fn(( _key: string, shouldCommit?: () => boolean, @@ -229,6 +237,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { resetSavingsPopupCooldown: vi.fn(), restoreWidgetState: vi.fn(), resetStreamLiveTurnState: vi.fn(), + retireAttachments, }) const toB = runtime.switchToSession('agent:main:webchat:b') @@ -243,6 +252,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { expect(cancelSessionBootstrap).not.toHaveBeenCalled() expect(beginSessionResolution).not.toHaveBeenCalled() expect(persistSession).not.toHaveBeenCalled() + expect(retireAttachments).not.toHaveBeenCalled() const commitGuard = switchPendingQueue.mock.calls[0]?.[1] expect(commitGuard?.()).toBe(false) }) @@ -251,6 +261,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { const sessionKey = ref('agent:main:webchat:a') const cancelSessionBootstrap = vi.fn() const beginSessionResolution = vi.fn() + const retireAttachments = vi.fn() const failure = new Error('queue adoption failed') const runtime = useChatSessionRuntime({ sessionKey, @@ -280,11 +291,65 @@ describe('useChatSessionRuntime Meta draft recovery', () => { resetSavingsPopupCooldown: vi.fn(), restoreWidgetState: vi.fn(), resetStreamLiveTurnState: vi.fn(), + retireAttachments, }) await expect(runtime.switchToSession('agent:main:webchat:b')).rejects.toBe(failure) expect(sessionKey.value).toBe('agent:main:webchat:a') expect(cancelSessionBootstrap).not.toHaveBeenCalled() expect(beginSessionResolution).not.toHaveBeenCalled() + expect(retireAttachments).not.toHaveBeenCalled() + }) + + it('preserves attachments for same-key navigation and response handoff', async () => { + const sessionKey = ref('agent:main:webchat:a') + const retireAttachments = vi.fn() + const runtime = useChatSessionRuntime({ + sessionKey, + messages: ref([]), + pendingSessionIntent: ref(null), + routerDecisionPending: ref(null), + currentEpoch: ref(0), + lastStreamSeq: ref(0), + activeTaskGroups: ref(new Set()), + aborted: ref(false), + lastHeaderRole: ref(''), + lastHeaderDay: ref(''), + usageAccum: ref(emptyUsage()), + usageModel: ref(''), + createSessionKey: () => '', + persistSession: key => { sessionKey.value = key }, + cancelSessionBootstrap: vi.fn(), + startSessionBootstrap: vi.fn(() => ({ + generation: 1, + criticalRequestsQueued: Promise.resolve(), + history: Promise.resolve({ ok: true }), + live: Promise.resolve({ + authoritative: true, + live: false, + backgroundOnly: false, + }), + })), + loadCurrentSessionUsage: vi.fn(), + applySessionRunState: vi.fn(), + setCompactInFlight: vi.fn(), + hideCompactStatus: vi.fn(), + clearPendingQueue: vi.fn(), + switchPendingQueue: vi.fn(), + adoptPendingQueue: vi.fn(), + resetSavingsPopupCooldown: vi.fn(), + restoreWidgetState: vi.fn(), + resetStreamLiveTurnState: vi.fn(), + retireAttachments, + }) + + await runtime.switchToSession('agent:main:webchat:a') + await runtime.adoptResponseSession('agent:main:webchat:b', 'request-a') + + expect(sessionKey.value).toBe('agent:main:webchat:b') + expect(retireAttachments).not.toHaveBeenCalled() + + await runtime.switchToSession('agent:main:webchat:a') + expect(retireAttachments).toHaveBeenCalledOnce() }) }) From c9c8c95b61137d2f7144b4be8950513a6670740f Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Tue, 1 Sep 2026 23:47:06 +0800 Subject: [PATCH 03/18] Preserve attachments during draft materialization --- .../chat/useChatSessionRuntime.test.ts | 20 +++++++++++---- .../composables/chat/useChatSessionRuntime.ts | 6 +++++ .../views/ChatView.goal-composer-mode.test.ts | 25 ++++++++++++++++--- opensquilla-webui/src/views/ChatView.vue | 11 +++++++- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts index 1579f9a98..55a1ac9b0 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { describe, expect, it, vi } from 'vitest' import { useChatSessionRuntime, type ChatUsageAccumulator } from './useChatSessionRuntime' -import type { ChatMessage } from '@/types/chat' +import type { Attachment, ChatMessage } from '@/types/chat' function emptyUsage(): ChatUsageAccumulator { return { @@ -301,9 +301,16 @@ describe('useChatSessionRuntime Meta draft recovery', () => { expect(retireAttachments).not.toHaveBeenCalled() }) - it('preserves attachments for same-key navigation and response handoff', async () => { + it('preserves attachments for same-key navigation and canonical adoption', async () => { const sessionKey = ref('agent:main:webchat:a') - const retireAttachments = vi.fn() + const pendingAttachments = ref([{ + kind: 'inline', + local_id: 1, + name: 'goal-context.txt', + mime: 'text/plain', + data: 'Z29hbCBjb250ZXh0', + }]) + const retireAttachments = vi.fn(() => { pendingAttachments.value = [] }) const runtime = useChatSessionRuntime({ sessionKey, messages: ref([]), @@ -344,12 +351,15 @@ describe('useChatSessionRuntime Meta draft recovery', () => { }) await runtime.switchToSession('agent:main:webchat:a') - await runtime.adoptResponseSession('agent:main:webchat:b', 'request-a') + await runtime.adoptMaterializedSession('agent:main:webchat:b') + await runtime.adoptResponseSession('agent:main:webchat:c', 'request-a') - expect(sessionKey.value).toBe('agent:main:webchat:b') + expect(sessionKey.value).toBe('agent:main:webchat:c') expect(retireAttachments).not.toHaveBeenCalled() + expect(pendingAttachments.value).toHaveLength(1) await runtime.switchToSession('agent:main:webchat:a') expect(retireAttachments).toHaveBeenCalledOnce() + expect(pendingAttachments.value).toEqual([]) }) }) diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts index ce010167c..c9f77a998 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts @@ -181,6 +181,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { key: string, pendingQueuePolicy: | { kind: 'navigate' } + | { kind: 'draft_materialization' } | { kind: 'response_handoff'; ownerRequestId: string }, ): Promise { if (!key) return @@ -267,6 +268,10 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { return switchSession(key, { kind: 'response_handoff', ownerRequestId }) } + function adoptMaterializedSession(key: string) { + return switchSession(key, { kind: 'draft_materialization' }) + } + async function rebindDraftSession( key: string, guard: DraftSessionRebindGuard, @@ -364,6 +369,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { resetCurrentSessionAfterSlash, startDraftSession, switchToSession, + adoptMaterializedSession, adoptResponseSession, rebindDraftSession, } diff --git a/opensquilla-webui/src/views/ChatView.goal-composer-mode.test.ts b/opensquilla-webui/src/views/ChatView.goal-composer-mode.test.ts index b4e2a6a90..b3f69d7d2 100644 --- a/opensquilla-webui/src/views/ChatView.goal-composer-mode.test.ts +++ b/opensquilla-webui/src/views/ChatView.goal-composer-mode.test.ts @@ -28,7 +28,7 @@ describe('ChatView Goal and Plan composer mode exclusivity', () => { const bindProject = source.indexOf( 'freshTaskDraft.bindMaterializedProjectTask(key, workspaceId)', ) - const switchSession = source.indexOf('await switchToSession(key)') + const adoptSession = source.indexOf('await adoptMaterializedSession(key)') expect(start).toBeGreaterThanOrEqual(0) expect(end).toBeGreaterThan(start) @@ -42,10 +42,27 @@ describe('ChatView Goal and Plan composer mode exclusivity', () => { expect(staleNavigationFence).toBeGreaterThan(createSession) expect(staleProjectFence).toBeGreaterThan(staleNavigationFence) expect(persistRouting).toBeGreaterThan(staleProjectFence) - expect(persistRouting).toBeLessThan(switchSession) + expect(persistRouting).toBeLessThan(adoptSession) expect(bindProject).toBeGreaterThan(staleProjectFence) - expect(bindProject).toBeLessThan(switchSession) - expect(switchSession).toBeGreaterThan(createSession) + expect(bindProject).toBeLessThan(adoptSession) + expect(adoptSession).toBeGreaterThan(createSession) + expect(source).not.toContain('await switchToSession(key)') + }) + + it('preserves Goal draft attachments for accepted and rejected registration', () => { + const start = chatViewSource.indexOf('async function onComposerSend()') + const end = chatViewSource.indexOf('\nsendCurrentInput = onComposerSend', start) + const source = chatViewSource.slice(start, end) + const startGoal = source.indexOf('const started = await startGoal(goalText)') + const rejectGoal = source.indexOf('if (!started) return', startGoal) + const clearText = source.indexOf("inputText.value = ''", rejectGoal) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(startGoal).toBeGreaterThanOrEqual(0) + expect(rejectGoal).toBeGreaterThan(startGoal) + expect(clearText).toBeGreaterThan(rejectGoal) + expect(source).not.toContain('pendingAttachments.value') }) it('projects the durably accepted Goal source row before history catches up', () => { diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 373eb63b0..c0c406ab0 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -2924,6 +2924,7 @@ const { resetCurrentSessionAfterSlash, startDraftSession, switchToSession: switchRuntimeToSession, + adoptMaterializedSession: adoptRuntimeMaterializedSession, adoptResponseSession, rebindDraftSession, } = chatSessionRuntime @@ -2937,6 +2938,14 @@ async function switchToSession(nextSessionKey: string) { return outcome } +async function adoptMaterializedSession(nextSessionKey: string) { + const outcome = await adoptRuntimeMaterializedSession(nextSessionKey) + if (outcome?.authoritative) { + await handleAuthoritativeSessionSubscription(nextSessionKey) + } + return outcome +} + const metaSkillSetup = useMetaSkillSetup({ metaRunCenter, currentSessionKey: sessionKey, @@ -3086,7 +3095,7 @@ const chatGoals = useChatGoals({ ) return '' } if (workspaceId) freshTaskDraft.bindMaterializedProjectTask(key, workspaceId) - await switchToSession(key) + await adoptMaterializedSession(key) return key }, ensureSubscribed: async key => { From 22b9e607d958d32499d274f4567de8e5808a45af Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 00:19:58 +0800 Subject: [PATCH 04/18] Harden attachment session lifecycle boundaries --- .../chat/useChatAttachments.test.ts | 294 +++++++++++++++++- .../composables/chat/useChatAttachments.ts | 191 +++++++----- .../composables/chat/useChatPendingQueue.ts | 9 +- .../chat/useChatSend.attachments.test.ts | 9 + .../src/composables/chat/useChatSend.ts | 28 +- .../chat/useChatSessionRuntime.test.ts | 222 +++++++++++++ .../composables/chat/useChatSessionRuntime.ts | 64 ++-- 7 files changed, 705 insertions(+), 112 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts index 54b9689aa..bb51536fb 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts @@ -168,8 +168,10 @@ describe('useChatAttachments', () => { const adding = attachments.addAttachment(file) expect(attachments.pendingAttachments.value).toEqual([]) + expect(attachments.hasPendingAttachmentWork()).toBe(true) attachments.retireAttachments() + expect(attachments.hasPendingAttachmentWork()).toBe(false) const sessionBAttachment = nextSessionAttachment(1) attachments.pendingAttachments.value = [sessionBAttachment] settle(sniff) @@ -177,9 +179,86 @@ describe('useChatAttachments', () => { expect(ControlledFileReader.instances).toHaveLength(0) expect(attachments.pendingAttachments.value).toEqual([sessionBAttachment]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) expect(pushToast).not.toHaveBeenCalled() }) + it('keeps an unknown-MIME selection in the send busy gate until its placeholder is ready', async () => { + vi.stubGlobal('FileReader', ControlledFileReader) + const attachments = useTestChatAttachments() + const sniff = deferred() + const file = new File(['selected context'], 'context.unknown', { type: 'application/x-unknown' }) + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn(() => sniff.promise), + }) + + const adding = attachments.addAttachment(file) + + expect(attachments.pendingAttachments.value).toEqual([]) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + + sniff.resolve(new TextEncoder().encode('selected context').buffer) + await adding + + expect(ControlledFileReader.instances).toHaveLength(1) + expect(attachments.pendingAttachments.value).toMatchObject([ + { kind: 'inline_pending', name: 'context.unknown' }, + ]) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + + ControlledFileReader.instances[0]!.succeed('data:text/plain;base64,c2VsZWN0ZWQgY29udGV4dA==') + + expect(attachments.pendingAttachments.value).toMatchObject([ + { kind: 'inline', name: 'context.unknown' }, + ]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + }) + + it('does not let retired MIME-sniff cleanup release next-generation intake', async () => { + vi.stubGlobal('FileReader', ControlledFileReader) + const attachments = useTestChatAttachments() + const firstSniff = deferred() + const secondSniff = deferred() + const firstFile = new File(['first'], 'first.unknown', { type: 'application/x-unknown' }) + const secondFile = new File(['second'], 'second.unknown', { type: 'application/x-unknown' }) + Object.defineProperty(firstFile, 'arrayBuffer', { + configurable: true, + value: vi.fn(() => firstSniff.promise), + }) + Object.defineProperty(secondFile, 'arrayBuffer', { + configurable: true, + value: vi.fn(() => secondSniff.promise), + }) + + const firstAdding = attachments.addAttachment(firstFile) + attachments.retireAttachments() + const secondAdding = attachments.addAttachment(secondFile) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + + firstSniff.resolve(new TextEncoder().encode('first').buffer) + await firstAdding + + expect(ControlledFileReader.instances).toHaveLength(0) + expect(attachments.pendingAttachments.value).toEqual([]) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + + secondSniff.resolve(new TextEncoder().encode('second').buffer) + await secondAdding + + expect(ControlledFileReader.instances).toHaveLength(1) + expect(attachments.pendingAttachments.value).toMatchObject([ + { kind: 'inline_pending', name: 'second.unknown' }, + ]) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + + ControlledFileReader.instances[0]!.succeed('data:text/plain;base64,c2Vjb25k') + expect(attachments.pendingAttachments.value).toMatchObject([ + { kind: 'inline', name: 'second.unknown' }, + ]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + }) + it.each(['load', 'error'] as const)( 'ignores a delayed FileReader %s callback after attachments retire', async outcome => { @@ -312,6 +391,214 @@ describe('useChatAttachments', () => { expect(pushToast).not.toHaveBeenCalled() }) + it('keeps a detached handoff refresh alive when the visible composer retires', async () => { + const upload = deferred() + vi.stubGlobal('fetch', vi.fn(() => upload.promise)) + const attachments = useTestChatAttachments() + const handoffFile = stagedPdf('handoff-refresh.pdf') + const handoffAttachments: Attachment[] = [{ + kind: 'staged', + local_id: 1, + name: handoffFile.name, + mime: handoffFile.type, + file_uuid: 'file-handoff-expired', + expires_at: Date.now() / 1000 - 1, + file: handoffFile, + }] + + const preparing = attachments.prepareAttachmentsForSend({ + attachments: handoffAttachments, + isCurrent: () => true, + ownership: 'detached', + }) + + expect(attachments.hasPendingAttachmentWork()).toBe(false) + attachments.retireAttachments() + const sessionBAttachment = nextSessionAttachment(1) + attachments.pendingAttachments.value = [sessionBAttachment] + upload.resolve(successfulUploadResponse('file-handoff-fresh')) + + await expect(preparing).resolves.toBe(true) + expect(handoffAttachments).toMatchObject([ + { kind: 'staged', file_uuid: 'file-handoff-fresh' }, + ]) + expect(attachments.pendingAttachments.value).toEqual([sessionBAttachment]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + expect(pushToast).not.toHaveBeenCalled() + }) + + it('isolates equal local IDs across concurrent detached attachment collections', async () => { + const firstUpload = deferred() + const secondUpload = deferred() + const fetchMock = vi.fn() + .mockImplementationOnce(() => firstUpload.promise) + .mockImplementationOnce(() => secondUpload.promise) + vi.stubGlobal('fetch', fetchMock) + const attachments = useTestChatAttachments() + const firstFile = stagedPdf('first-handoff.pdf') + const secondFile = stagedPdf('second-handoff.pdf') + const firstCollection: Attachment[] = [{ + kind: 'staged', + local_id: 1, + name: firstFile.name, + mime: firstFile.type, + file_uuid: 'first-expired', + expires_at: 0, + file: firstFile, + }] + const secondCollection: Attachment[] = [{ + kind: 'staged', + local_id: 1, + name: secondFile.name, + mime: secondFile.type, + file_uuid: 'second-expired', + expires_at: 0, + file: secondFile, + }] + + const firstPreparation = attachments.prepareAttachmentsForSend({ + ownership: 'detached', + attachments: firstCollection, + isCurrent: () => true, + }) + const secondPreparation = attachments.prepareAttachmentsForSend({ + ownership: 'detached', + attachments: secondCollection, + isCurrent: () => true, + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + firstUpload.resolve(successfulUploadResponse('first-fresh')) + secondUpload.resolve(successfulUploadResponse('second-fresh')) + + await expect(firstPreparation).resolves.toBe(true) + await expect(secondPreparation).resolves.toBe(true) + expect(firstCollection).toMatchObject([{ file_uuid: 'first-fresh' }]) + expect(secondCollection).toMatchObject([{ file_uuid: 'second-fresh' }]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + }) + + it('refreshes duplicate restored IDs by attachment identity within one collection', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(successfulUploadResponse('first-fresh')) + .mockResolvedValueOnce(successfulUploadResponse('second-fresh')) + vi.stubGlobal('fetch', fetchMock) + const attachments = useTestChatAttachments() + const firstFile = stagedPdf('first-restored.pdf') + const secondFile = stagedPdf('second-restored.pdf') + const restored: Attachment[] = [ + { + kind: 'staged', + local_id: 1, + name: firstFile.name, + mime: firstFile.type, + file_uuid: 'first-expired', + expires_at: 0, + file: firstFile, + }, + { + kind: 'staged', + local_id: 1, + name: secondFile.name, + mime: secondFile.type, + file_uuid: 'second-expired', + expires_at: 0, + file: secondFile, + }, + ] + + await expect(attachments.prepareAttachmentsForSend({ + ownership: 'detached', + attachments: restored, + isCurrent: () => true, + })).resolves.toBe(true) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(restored).toMatchObject([ + { local_id: 1, name: 'first-restored.pdf', file_uuid: 'first-fresh' }, + { local_id: 1, name: 'second-restored.pdf', file_uuid: 'second-fresh' }, + ]) + }) + + it('keeps the collection lock registered across a multi-attachment refresh', async () => { + const firstUpload = deferred() + const secondUpload = deferred() + const fetchMock = vi.fn() + .mockImplementationOnce(() => firstUpload.promise) + .mockImplementationOnce(() => secondUpload.promise) + vi.stubGlobal('fetch', fetchMock) + const attachments = useTestChatAttachments() + const firstFile = stagedPdf('first.pdf') + const secondFile = stagedPdf('second.pdf') + const collection: Attachment[] = [ + { + kind: 'staged', + local_id: 1, + name: firstFile.name, + mime: firstFile.type, + file_uuid: 'first-expired', + expires_at: 0, + file: firstFile, + }, + { + kind: 'staged', + local_id: 2, + name: secondFile.name, + mime: secondFile.type, + file_uuid: 'second-expired', + expires_at: 0, + file: secondFile, + }, + ] + const options = { + ownership: 'detached' as const, + attachments: collection, + isCurrent: () => true, + } + + const firstPreparation = attachments.prepareAttachmentsForSend(options) + firstUpload.resolve(successfulUploadResponse('first-fresh')) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + + await expect(attachments.prepareAttachmentsForSend(options)).resolves.toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(2) + + secondUpload.resolve(successfulUploadResponse('second-fresh')) + await expect(firstPreparation).resolves.toBe(true) + expect(collection).toMatchObject([ + { local_id: 1, file_uuid: 'first-fresh' }, + { local_id: 2, file_uuid: 'second-fresh' }, + ]) + }) + + it('allocates around restored attachment IDs before starting async work', async () => { + vi.stubGlobal('FileReader', ControlledFileReader) + const attachments = useTestChatAttachments() + const restoredAttachment: Attachment = { + kind: 'inline', + local_id: 1, + name: 'restored.txt', + mime: 'text/plain', + data: 'cmVzdG9yZWQ=', + } + attachments.pendingAttachments.value = [restoredAttachment] + + await attachments.addAttachment( + new File(['new attachment'], 'new.txt', { type: 'text/plain' }), + ) + + expect(attachments.pendingAttachments.value).toMatchObject([ + { local_id: 1, kind: 'inline', name: 'restored.txt' }, + { local_id: 2, kind: 'inline_pending', name: 'new.txt' }, + ]) + ControlledFileReader.instances[0]!.succeed('data:text/plain;base64,bmV3IGF0dGFjaG1lbnQ=') + + expect(attachments.pendingAttachments.value).toMatchObject([ + { local_id: 1, kind: 'inline', name: 'restored.txt', data: 'cmVzdG9yZWQ=' }, + { local_id: 2, kind: 'inline', name: 'new.txt', data: 'bmV3IGF0dGFjaG1lbnQ=' }, + ]) + }) + it('accepts every file type in a mixed batch (opaque binaries included)', async () => { const fetchMock = vi.fn().mockResolvedValue(successfulUploadResponse('file-valid')) vi.stubGlobal('fetch', fetchMock) @@ -633,6 +920,8 @@ describe('useChatAttachments', () => { const ready = await attachments.prepareAttachmentsForSend({ attachments: queuedAttachments, + ownership: 'detached', + isCurrent: () => true, }) expect(ready).toBe(true) @@ -683,7 +972,10 @@ describe('useChatAttachments', () => { attachments.pendingAttachments.value = [stagedAttachment] let current = true - const ready = attachments.prepareAttachmentsForSend({ isCurrent: () => current }) + const ready = attachments.prepareAttachmentsForSend({ + ownership: 'composer', + isCurrent: () => current, + }) expect(fetchMock).toHaveBeenCalledTimes(1) expect(attachments.hasPendingAttachmentWork()).toBe(true) diff --git a/opensquilla-webui/src/composables/chat/useChatAttachments.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.ts index c2d15b0e7..2f1f9300f 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.ts @@ -28,14 +28,16 @@ type UploadResponseMeta = { ttlSeconds?: number } -type AttachmentPreparationOptions = { +export type AttachmentPreparationOptions = { + ownership: 'composer' isCurrent?: () => boolean - /** - * Refresh this attachment collection instead of the visible composer. - * Queued sends use their own snapshot and must never borrow or mutate a - * draft that the operator is still editing. - */ + /** A send snapshot can remain composer-owned even though it is a cloned array. */ attachments?: Attachment[] +} | { + ownership: 'detached' + /** Detached callers own cancellation; composer retirement must not invalidate them. */ + isCurrent: () => boolean + attachments: Attachment[] } // Per-addAttachments-call state so batch-wide rejections (the aggregate size @@ -116,11 +118,13 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { const { pushToast } = useToasts() const pendingAttachments = ref([]) const nextAttachmentId = ref(1) - const refreshInFlightAttachmentIds = new Set() - const refreshInFlightAttachmentCount = ref(0) + const refreshInFlightByCollection = new WeakMap>() + const composerRefreshInFlightCount = ref(0) + const attachmentIntakeInFlightCount = ref(0) let attachmentGeneration = 0 const attachmentWorkBusy = computed(() => - refreshInFlightAttachmentCount.value > 0 + attachmentIntakeInFlightCount.value > 0 + || composerRefreshInFlightCount.value > 0 || pendingAttachments.value.some( attachment => attachment.kind === 'inline_pending' || attachment.kind === 'uploading', ), @@ -165,75 +169,99 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } let mime = resolveAttachmentMime(file) - if (!isAllowedAttachmentMime(mime)) { - const looksLikeText = await fileLooksLikeUtf8Text(file) - if (!isAttachmentGenerationCurrent(batch.generation)) return - if (looksLikeText) { - // Unknown-but-textual uploads degrade to text/plain so the gateway's - // UTF-8 fallback is reachable from the WebUI (the gateway re-validates). - mime = 'text/plain' + const requiresMimeSniff = !isAllowedAttachmentMime(mime) + if (requiresMimeSniff) attachmentIntakeInFlightCount.value += 1 + try { + if (requiresMimeSniff) { + const looksLikeText = await fileLooksLikeUtf8Text(file) + if (!isAttachmentGenerationCurrent(batch.generation)) return + if (looksLikeText) { + // Unknown-but-textual uploads degrade to text/plain so the gateway's + // UTF-8 fallback is reachable from the WebUI (the gateway re-validates). + mime = 'text/plain' + } + // Anything else is an opaque attachment: it uploads under its resolved + // label and the gateway stages the bytes for the agent workspace. + } + const hardCap = attachmentHardCapBytes(mime) + if (file.size > hardCap) { + pushToast(i18n.global.t('chat.toast.fileTooLarge', { name: fileName, cap: formatMiB(hardCap) }), { tone: 'danger' }) + return + } + if (!canAcceptAttachment(fileName, file.size, batch)) return + + const localId = allocateAttachmentId() + + if (file.size <= INLINE_THRESHOLD_BYTES) { + const placeholder: Attachment = { + kind: 'inline_pending', + local_id: localId, + name: fileName, + mime, + size: file.size, + file, + } + pendingAttachments.value.push(placeholder) + const reader = new FileReader() + reader.onload = (e) => { + if (!isAttachmentGenerationCurrent(batch.generation)) return + const dataUrl = e.target?.result as string + const b64 = dataUrl?.split(',')[1] || '' + const idx = pendingAttachments.value.indexOf(placeholder) + if (idx >= 0) { + pendingAttachments.value[idx] = { kind: 'inline', local_id: localId, name: fileName, mime, size: file.size, data: b64, dataUrl, file } + } + } + reader.onerror = () => { + if (!isAttachmentGenerationCurrent(batch.generation)) return + const message = i18n.global.t('chat.toast.couldNotReadFile', { name: fileName }) + markAttachmentFailed(localId, file, mime, message, pendingAttachments.value, placeholder) + pushToast(message, { tone: 'danger' }) + } + reader.readAsDataURL(file) + return } - // Anything else is an opaque attachment: it uploads under its resolved - // label and the gateway stages the bytes for the agent workspace. - } - const hardCap = attachmentHardCapBytes(mime) - if (file.size > hardCap) { - pushToast(i18n.global.t('chat.toast.fileTooLarge', { name: fileName, cap: formatMiB(hardCap) }), { tone: 'danger' }) - return - } - if (!canAcceptAttachment(fileName, file.size, batch)) return - const localId = nextAttachmentId.value++ + if (!canStageAttachmentMime(mime)) { + pushToast(i18n.global.t('chat.toast.fileTooLarge', { name: fileName, cap: formatMiB(hardCap) }), { tone: 'danger' }) + return + } - if (file.size <= INLINE_THRESHOLD_BYTES) { - pendingAttachments.value.push({ kind: 'inline_pending', local_id: localId, name: fileName, mime, size: file.size, file }) - const reader = new FileReader() - reader.onload = (e) => { - if (!isAttachmentGenerationCurrent(batch.generation)) return - const dataUrl = e.target?.result as string - const b64 = dataUrl?.split(',')[1] || '' - const idx = pendingAttachments.value.findIndex(a => a.local_id === localId) - if (idx >= 0) { - pendingAttachments.value[idx] = { kind: 'inline', local_id: localId, name: fileName, mime, size: file.size, data: b64, dataUrl, file } - } + const placeholder: Attachment = { + kind: 'uploading', + local_id: localId, + name: fileName, + mime, + size: file.size, + file, } - reader.onerror = () => { + pendingAttachments.value.push(placeholder) + uploadAttachmentStaged(file, mime, placeholder, batch.generation).catch((err) => { if (!isAttachmentGenerationCurrent(batch.generation)) return - const message = i18n.global.t('chat.toast.couldNotReadFile', { name: fileName }) - markAttachmentFailed(localId, file, mime, message) - pushToast(message, { tone: 'danger' }) + const message = uploadFailureMessage(err) + markAttachmentFailed(localId, file, mime, message, pendingAttachments.value, placeholder) + pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: fileName })}: ${message}`, { tone: 'danger' }) + }) + } finally { + if (requiresMimeSniff && isAttachmentGenerationCurrent(batch.generation)) { + attachmentIntakeInFlightCount.value = Math.max(0, attachmentIntakeInFlightCount.value - 1) } - reader.readAsDataURL(file) - return - } - - if (!canStageAttachmentMime(mime)) { - pushToast(i18n.global.t('chat.toast.fileTooLarge', { name: fileName, cap: formatMiB(hardCap) }), { tone: 'danger' }) - return } - - pendingAttachments.value.push({ kind: 'uploading', local_id: localId, name: fileName, mime, size: file.size, file }) - uploadAttachmentStaged(file, mime, localId, batch.generation).catch((err) => { - if (!isAttachmentGenerationCurrent(batch.generation)) return - const message = uploadFailureMessage(err) - markAttachmentFailed(localId, file, mime, message) - pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: fileName })}: ${message}`, { tone: 'danger' }) - }) } async function uploadAttachmentStaged( file: File, mime: string, - localId: number, + placeholder: Attachment, generation: number, ) { const meta = await uploadAttachmentFile(file, mime) if (!isAttachmentGenerationCurrent(generation)) return - const idx = pendingAttachments.value.findIndex(a => a.local_id === localId) + const idx = pendingAttachments.value.indexOf(placeholder) if (idx >= 0) { pendingAttachments.value[idx] = { kind: 'staged', - local_id: localId, + local_id: placeholder.local_id, name: file.name || 'Untitled file', mime, size: file.size, @@ -256,9 +284,10 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { function retireAttachments() { attachmentGeneration += 1 + refreshInFlightByCollection.delete(pendingAttachments.value) pendingAttachments.value = [] - refreshInFlightAttachmentIds.clear() - refreshInFlightAttachmentCount.value = 0 + composerRefreshInFlightCount.value = 0 + attachmentIntakeInFlightCount.value = 0 } async function retryAttachment(index: number) { @@ -278,8 +307,11 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { mime: string, error: string, attachments: Attachment[] = pendingAttachments.value, + expectedAttachment?: Attachment, ) { - const idx = attachments.findIndex(a => a.local_id === localId) + const idx = expectedAttachment + ? attachments.indexOf(expectedAttachment) + : attachments.findIndex(attachment => attachment.local_id === localId) if (idx >= 0) { attachments[idx] = { kind: 'failed', @@ -297,18 +329,26 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { return attachmentWorkBusy.value } - async function prepareAttachmentsForSend(options: AttachmentPreparationOptions = {}): Promise { + async function prepareAttachmentsForSend( + options: AttachmentPreparationOptions = { ownership: 'composer' }, + ): Promise { const isCurrent = options.isCurrent ?? (() => true) + const composerOwned = options.ownership !== 'detached' const generation = attachmentGeneration const preparationIsCurrent = () => ( - isAttachmentGenerationCurrent(generation) && isCurrent() + (!composerOwned || isAttachmentGenerationCurrent(generation)) && isCurrent() ) const attachments = options.attachments ?? pendingAttachments.value + let refreshInFlightAttachments = refreshInFlightByCollection.get(attachments) + if (!refreshInFlightAttachments) { + refreshInFlightAttachments = new Set() + refreshInFlightByCollection.set(attachments, refreshInFlightAttachments) + } const staged = [...attachments].filter(stagedUploadNeedsRefresh) for (const attachment of staged) { if (!preparationIsCurrent()) return false - if (refreshInFlightAttachmentIds.has(attachment.local_id)) return false - const idx = attachments.findIndex(a => a.local_id === attachment.local_id) + if (refreshInFlightAttachments.has(attachment)) return false + const idx = attachments.indexOf(attachment) if (idx < 0 || attachments[idx].kind !== 'staged') continue if (!attachment.file) { attachments[idx] = { @@ -322,12 +362,12 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { pushToast(`Upload expired for ${attachment.name}: select the file again`, { tone: 'danger' }) return false } - refreshInFlightAttachmentIds.add(attachment.local_id) - refreshInFlightAttachmentCount.value = refreshInFlightAttachmentIds.size + refreshInFlightAttachments.add(attachment) + if (composerOwned) composerRefreshInFlightCount.value += 1 try { const meta = await uploadAttachmentFile(attachment.file, attachment.mime) if (!preparationIsCurrent()) return false - const currentIdx = attachments.findIndex(a => a.local_id === attachment.local_id) + const currentIdx = attachments.indexOf(attachment) if (currentIdx < 0 || attachments[currentIdx].kind !== 'staged') continue attachments[currentIdx] = { kind: 'staged', @@ -349,13 +389,14 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { attachment.mime, message, attachments, + attachment, ) pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: attachment.name })}: ${message}`, { tone: 'danger' }) return false } finally { - if (isAttachmentGenerationCurrent(generation)) { - refreshInFlightAttachmentIds.delete(attachment.local_id) - refreshInFlightAttachmentCount.value = refreshInFlightAttachmentIds.size + const removed = refreshInFlightAttachments.delete(attachment) + if (composerOwned && removed && isAttachmentGenerationCurrent(generation)) { + composerRefreshInFlightCount.value = Math.max(0, composerRefreshInFlightCount.value - 1) } } } @@ -366,6 +407,12 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { return pendingAttachments.value.filter(attachmentCountsTowardLimits).length } + function allocateAttachmentId(): number { + const currentIds = new Set(pendingAttachments.value.map(attachment => attachment.local_id)) + while (currentIds.has(nextAttachmentId.value)) nextAttachmentId.value += 1 + return nextAttachmentId.value++ + } + function canAcceptAttachment(fileName: string, size: number, batch: AttachmentBatch): boolean { if (!isAttachmentGenerationCurrent(batch.generation)) return false const activeAttachments = pendingAttachments.value.filter(attachmentCountsTowardLimits) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index bb218e55d..23f16a2a4 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -6,6 +6,7 @@ import type { PendingSteerPhase, } from '@/types/chat' import type { SessionSteerV2Params } from '@/types/chat' +import type { AttachmentPreparationOptions } from '@/composables/chat/useChatAttachments' import { isControlInput } from '@/utils/chat/inputSemantics' import { createClientMessageId, createClientRequestId } from '@/utils/chat/messageIdentity' import { @@ -134,10 +135,9 @@ export interface UseChatPendingQueueOptions { pendingInputWal?: PendingInputWal | null pendingInputQueue?: PendingInputQueuePort | null connectionState?: Readonly> - prepareAttachmentsForSend?: (options: { - attachments: Attachment[] - isCurrent?: () => boolean - }) => Promise + prepareAttachmentsForSend?: ( + options: Extract, + ) => Promise onPendingPersistenceError?: ( reason: 'wal_failed' | 'attachments_unsupported' | 'server_rejected' | 'order_conflict', ) => void @@ -486,6 +486,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { isCurrent: () => pendingQueue.value.some(candidate => ( candidate.pendingInputId === pendingInputId )), + ownership: 'detached', }) if (!ready) { await writeWalItem(item, 'retryable') diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index bd671de05..a3f83a1fe 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -1356,6 +1356,10 @@ describe('useChatSend attachment payloads', () => { await api.recoverResponseHandoffs() expect(prepareAttachmentsForSend).toHaveBeenCalledOnce() + expect(prepareAttachmentsForSend).toHaveBeenCalledWith(expect.objectContaining({ + ownership: 'detached', + isCurrent: expect.any(Function), + })) expect(rpc.call).toHaveBeenCalledTimes(2) const replay = rpc.call.mock.calls[1]?.[1] as { attachments?: Array<{ file_uuid?: string }> } expect(replay.attachments?.[0]?.file_uuid).toBe('refreshed-upload') @@ -2939,6 +2943,11 @@ describe('useChatSend attachment payloads', () => { await api.onSend() expect(prepareAttachmentsForSend).toHaveBeenCalledTimes(1) + expect(prepareAttachmentsForSend).toHaveBeenCalledWith(expect.objectContaining({ + ownership: 'composer', + attachments: expect.any(Array), + isCurrent: expect.any(Function), + })) expect(rpc.call).toHaveBeenCalledWith('chat.send', expect.objectContaining({ attachments: [ { type: 'application/pdf', file_uuid: 'file-fresh', mime: 'application/pdf', name: 'ready.pdf' }, diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 326a49926..ece4a8a82 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -33,6 +33,7 @@ import type { import type { MetaRunCenter } from '@/modules/metaRunCenter' import type { ChatRpcStreamApi } from '@/composables/chat/useChatRpcEventHandlers' import type { ChatTaskOwnershipApi } from '@/composables/chat/useChatTaskOwnership' +import type { AttachmentPreparationOptions } from '@/composables/chat/useChatAttachments' import type { BusySendMode, PendingCancelOptions, @@ -227,6 +228,7 @@ interface ComposerSnapshot { } interface DispatchSendOptions { + attachmentOwnership: 'composer' | 'detached' composerText?: string promptAnnotationIds?: readonly string[] queueMode?: 'steer' @@ -553,10 +555,7 @@ export interface UseChatSendOptions { bindActiveStreamTask?: (taskId: string) => void isCompactInFlightForCurrentSession: () => boolean hasPendingAttachmentWork: () => boolean - prepareAttachmentsForSend?: (options?: { - isCurrent?: () => boolean - attachments?: Attachment[] - }) => Promise + prepareAttachmentsForSend?: (options?: AttachmentPreparationOptions) => Promise preparePromptAnnotationsForSend?: ( ids: readonly string[], options?: { isCurrent?: () => boolean }, @@ -1801,6 +1800,7 @@ export function useChatSend(options: UseChatSendOptions) { const ready = await options.prepareAttachmentsForSend!({ attachments: refreshed, isCurrent: () => true, + ownership: 'detached', }) const sendable = refreshed.filter(isSendableAttachment) if (ready && sendable.length === refreshed.length) { @@ -2289,6 +2289,7 @@ export function useChatSend(options: UseChatSendOptions) { if (options.sessionKey.value !== requestSessionKey) return if (replayBlockedReason?.value) return await dispatchSend(exactReplayAttempt.text, { + attachmentOwnership: 'detached', composerText, promptAnnotationIds: exactReplayAttempt.promptAnnotationIds, queueMode: exactReplayAttempt.queueMode, @@ -2344,6 +2345,7 @@ export function useChatSend(options: UseChatSendOptions) { }) ) { await dispatchSend(text, { + attachmentOwnership: 'composer', composerText, promptAnnotationIds: recoveredAttempt.promptAnnotationIds, queueMode: recoveredAttempt.queueMode, @@ -2483,6 +2485,7 @@ export function useChatSend(options: UseChatSendOptions) { if (!hasPayload || !options.sessionKey.value) return await dispatchSend(text, { + attachmentOwnership: 'composer', composerText, promptAnnotationIds: composerSnapshot.promptAnnotationIds, payload: payloadFromSnapshot(composerSnapshot), @@ -2644,6 +2647,7 @@ export function useChatSend(options: UseChatSendOptions) { return dispatchSteerV2(text, { queuedItem: item }) } const outcome = await dispatchSend(dispatchText, { + attachmentOwnership: 'detached', composerText: item.text, promptAnnotationIds: item.promptAnnotationIds || [], payload: { @@ -2686,7 +2690,7 @@ export function useChatSend(options: UseChatSendOptions) { async function dispatchSend( text: string, - sendOpts: DispatchSendOptions = {}, + sendOpts: DispatchSendOptions, ): Promise { const requestSessionKey = options.sessionKey.value if (!requestSessionKey) return 'not_sent' @@ -2816,10 +2820,15 @@ export function useChatSend(options: UseChatSendOptions) { ? sendOpts.durablePendingItem : undefined if (!retryAttempt && !serverStagedPendingItem && options.prepareAttachmentsForSend) { - const ready = await options.prepareAttachmentsForSend({ - isCurrent: () => options.sessionKey.value === requestSessionKey, - ...(sendOpts.payload ? { attachments: sourceAttachments } : {}), - }) + const isCurrent = () => options.sessionKey.value === requestSessionKey + const preparationOptions: AttachmentPreparationOptions = sendOpts.attachmentOwnership === 'detached' + ? { ownership: 'detached', attachments: sourceAttachments, isCurrent } + : { + ownership: 'composer', + isCurrent, + ...(sendOpts.payload ? { attachments: sourceAttachments } : {}), + } + const ready = await options.prepareAttachmentsForSend(preparationOptions) if (!ready) return 'not_sent' if (options.sessionKey.value !== requestSessionKey) return 'not_sent' if (!preDispatchAllowed()) return 'not_sent' @@ -3504,6 +3513,7 @@ export function useChatSend(options: UseChatSendOptions) { usageBarrierReplayInFlight = true try { const outcome = await dispatchSend(text, { + attachmentOwnership: 'detached', payload: { attachments: [], intent: null, diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts index 55a1ac9b0..7e0fc2637 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts @@ -16,6 +16,88 @@ function emptyUsage(): ChatUsageAccumulator { } } +function runtimeHarness( + initialSessionKey: string, + generatedSessionKey = 'agent:main:webchat:generated', +) { + const sessionKey = ref(initialSessionKey) + const pendingSessionIntent = ref(null) + const persistSession = vi.fn((key: string) => { sessionKey.value = key }) + const beginSessionResolution = vi.fn() + const cancelSessionBootstrap = vi.fn() + const setSessionHandoffTarget = vi.fn() + const switchPendingQueue = vi.fn() + const adoptPendingQueue = vi.fn() + const retireAttachments = vi.fn() + const resetDraftComposer = vi.fn() + const bootstrappedSessionKeys: string[] = [] + const liveOutcome = { + authoritative: true, + live: false, + backgroundOnly: false, + } + const startSessionBootstrap = vi.fn(( + _options?: { includeHistory?: boolean; force?: boolean }, + ) => { + bootstrappedSessionKeys.push(sessionKey.value) + return { + generation: 1, + criticalRequestsQueued: Promise.resolve(), + history: Promise.resolve({ ok: true }), + live: Promise.resolve(liveOutcome), + } + }) + const runtime = useChatSessionRuntime({ + sessionKey, + messages: ref([]), + pendingSessionIntent, + routerDecisionPending: ref(null), + currentEpoch: ref(0), + lastStreamSeq: ref(0), + activeTaskGroups: ref(new Set()), + aborted: ref(false), + lastHeaderRole: ref(''), + lastHeaderDay: ref(''), + usageAccum: ref(emptyUsage()), + usageModel: ref(''), + createSessionKey: () => generatedSessionKey, + persistSession, + beginSessionResolution, + cancelSessionBootstrap, + setSessionHandoffTarget, + startSessionBootstrap, + loadCurrentSessionUsage: vi.fn(), + applySessionRunState: vi.fn(), + setCompactInFlight: vi.fn(), + hideCompactStatus: vi.fn(), + clearPendingQueue: vi.fn(), + switchPendingQueue, + adoptPendingQueue, + resetSavingsPopupCooldown: vi.fn(), + restoreWidgetState: vi.fn(), + resetStreamLiveTurnState: vi.fn(), + retireAttachments, + resetDraftComposer, + }) + + return { + adoptPendingQueue, + beginSessionResolution, + bootstrappedSessionKeys, + cancelSessionBootstrap, + liveOutcome, + pendingSessionIntent, + persistSession, + resetDraftComposer, + retireAttachments, + runtime, + sessionKey, + setSessionHandoffTarget, + startSessionBootstrap, + switchPendingQueue, + } +} + describe('useChatSessionRuntime Meta draft recovery', () => { it('rebinds an untouched provisional draft without persisting it', async () => { const sessionKey = ref('agent:main:webchat:local-draft') @@ -362,4 +444,144 @@ describe('useChatSessionRuntime Meta draft recovery', () => { expect(retireAttachments).toHaveBeenCalledOnce() expect(pendingAttachments.value).toEqual([]) }) + + it.each([ + ['short legacy key', 'agent:main:webchat:same', 'sess-same'], + ['default-agent key', 'agent:main:webchat:same', 'agent:default:webchat:same'], + ['trimmed key', 'agent:main:webchat:same', ' agent:main:webchat:same '], + ['default key', 'agent:main:webchat:default', ' default '], + ['webchat default key', 'agent:main:webchat:default', ' webchat:default '], + ['blank default key', ' ', 'default'], + ])('treats a %s alias as unchanged navigation', async ( + _label, + sourceKey, + targetAlias, + ) => { + const harness = runtimeHarness(sourceKey) + + await expect(harness.runtime.switchToSession(targetAlias)).resolves.toBeUndefined() + + expect(harness.setSessionHandoffTarget).toHaveBeenNthCalledWith( + 1, + sourceKey.trim() || 'agent:main:webchat:default', + 1, + ) + expect(harness.setSessionHandoffTarget).toHaveBeenLastCalledWith( + null, + 1, + 'unchanged', + ) + expect(harness.switchPendingQueue).not.toHaveBeenCalled() + expect(harness.persistSession).not.toHaveBeenCalled() + expect(harness.cancelSessionBootstrap).not.toHaveBeenCalled() + expect(harness.startSessionBootstrap).not.toHaveBeenCalled() + expect(harness.retireAttachments).not.toHaveBeenCalled() + expect(harness.sessionKey.value).toBe(sourceKey) + }) + + it('keeps every handoff policy inert for aliases of the current session', async () => { + const sourceAlias = ' agent:default:webchat:same ' + const harness = runtimeHarness(sourceAlias, 'sess-same') + + await harness.runtime.adoptMaterializedSession('sess-same') + await harness.runtime.adoptResponseSession( + ' agent:main:webchat:same ', + 'request-same', + ) + await expect(harness.runtime.rebindDraftSession( + 'agent:default:webchat:same', + sourceKey => sourceKey === sourceAlias, + )).resolves.toBe(false) + await harness.runtime.startDraftSession('main') + + expect(harness.switchPendingQueue).not.toHaveBeenCalled() + expect(harness.adoptPendingQueue).not.toHaveBeenCalled() + expect(harness.persistSession).not.toHaveBeenCalled() + expect(harness.cancelSessionBootstrap).not.toHaveBeenCalled() + expect(harness.startSessionBootstrap).not.toHaveBeenCalled() + expect(harness.retireAttachments).not.toHaveBeenCalled() + expect(harness.resetDraftComposer).not.toHaveBeenCalled() + expect(harness.sessionKey.value).toBe(sourceAlias) + expect( + harness.setSessionHandoffTarget.mock.calls + .filter(([target]) => target !== null) + .map(([target]) => target), + ).toEqual(Array(4).fill('agent:main:webchat:same')) + expect( + harness.setSessionHandoffTarget.mock.calls + .filter(([target]) => target === null) + .map(([, , outcome]) => outcome), + ).toEqual(Array(4).fill('unchanged')) + }) + + it('uses canonical keys throughout real switches while preserving queue and attachment policies', async () => { + const harness = runtimeHarness(' agent:default:webchat:a ', 'sess-f') + + await expect(harness.runtime.switchToSession(' sess-b ')).resolves.toEqual({ + authoritative: true, + authoritativeIdle: true, + backgroundOnly: false, + }) + await harness.runtime.adoptMaterializedSession('agent:default:webchat:c') + await harness.runtime.adoptResponseSession(' sess-d ', 'request-d') + await expect(harness.runtime.rebindDraftSession( + ' agent:default:webchat:e ', + sourceKey => sourceKey === 'agent:main:webchat:d', + )).resolves.toEqual(harness.liveOutcome) + await harness.runtime.startDraftSession('main') + + expect(harness.switchPendingQueue.mock.calls.map(([target]) => target)).toEqual([ + 'agent:main:webchat:b', + 'agent:main:webchat:c', + 'agent:main:webchat:e', + 'agent:main:webchat:f', + ]) + expect(harness.adoptPendingQueue).toHaveBeenCalledWith( + 'agent:main:webchat:d', + 'request-d', + expect.any(Function), + expect.anything(), + ) + expect(harness.beginSessionResolution.mock.calls.map(([target]) => target)).toEqual([ + 'agent:main:webchat:b', + 'agent:main:webchat:c', + 'agent:main:webchat:d', + ]) + expect(harness.persistSession.mock.calls.map(([target]) => target)).toEqual([ + 'agent:main:webchat:b', + 'agent:main:webchat:c', + 'agent:main:webchat:d', + ]) + expect(harness.bootstrappedSessionKeys).toEqual([ + 'agent:main:webchat:b', + 'agent:main:webchat:c', + 'agent:main:webchat:d', + 'agent:main:webchat:e', + 'agent:main:webchat:f', + ]) + expect(harness.startSessionBootstrap.mock.calls.map(([bootstrapOptions]) => ( + bootstrapOptions + ))).toEqual([ + { includeHistory: true }, + { includeHistory: true }, + { includeHistory: true }, + { includeHistory: false }, + { includeHistory: false }, + ]) + expect(harness.retireAttachments).toHaveBeenCalledTimes(2) + expect(harness.resetDraftComposer).toHaveBeenCalledOnce() + expect(harness.pendingSessionIntent.value).toBe('new_chat') + expect(harness.sessionKey.value).toBe('agent:main:webchat:f') + expect( + harness.setSessionHandoffTarget.mock.calls + .filter(([target]) => target !== null) + .map(([target]) => target), + ).toEqual([ + 'agent:main:webchat:b', + 'agent:main:webchat:c', + 'agent:main:webchat:d', + 'agent:main:webchat:e', + 'agent:main:webchat:f', + ]) + }) }) diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts index c9f77a998..c09bc3c49 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts @@ -11,6 +11,7 @@ import { beginSessionHandoffDiag, finishSessionHandoffDiag, } from '@/utils/chat/sessionNavigationDiag' +import { canonicalSessionKey } from '@/utils/chat/sessionKeys' export interface ChatUsageAccumulator { input: number @@ -120,7 +121,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { return ( handoffEpoch === epoch && handoffTargetKey === targetKey - && options.sessionKey.value === sourceKey + && canonicalSessionKey(options.sessionKey.value) === sourceKey ) } @@ -185,25 +186,26 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { | { kind: 'response_handoff'; ownerRequestId: string }, ): Promise { if (!key) return - const sourceKey = options.sessionKey.value - const { epoch, signal: handoffSignal } = beginHandoff(key) - if (key === sourceKey) { + const targetKey = canonicalSessionKey(key) + const sourceKey = canonicalSessionKey(options.sessionKey.value) + const { epoch, signal: handoffSignal } = beginHandoff(targetKey) + if (targetKey === sourceKey) { finishHandoff(epoch, 'unchanged') return } - const shouldCommit = () => isCurrentHandoff(epoch, key, sourceKey) + const shouldCommit = () => isCurrentHandoff(epoch, targetKey, sourceKey) try { if (pendingQueuePolicy.kind === 'response_handoff') { await options.adoptPendingQueue( - key, + targetKey, pendingQueuePolicy.ownerRequestId, shouldCommit, handoffSignal, ) } else { const pendingQueueSwitch = options.switchPendingQueue( - key, + targetKey, shouldCommit, handoffSignal, ) @@ -225,8 +227,8 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { // A's ACK and no connected event can observe a half-switched route. options.cancelSessionBootstrap() resetCompactState() - options.beginSessionResolution?.(key) - options.persistSession(key, { source: 'runtime.switchToSession' }) + options.beginSessionResolution?.(targetKey) + options.persistSession(targetKey, { source: 'runtime.switchToSession' }) resetSessionRuntimeState() options.pendingSessionIntent.value = null options.applySessionRunState({ run_status: 'idle' }) @@ -246,11 +248,14 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { void bootstrap.criticalRequestsQueued.then(() => { if ( handoffEpoch === epoch - && options.sessionKey.value === key + && canonicalSessionKey(options.sessionKey.value) === targetKey ) void options.loadCurrentSessionUsage() }) const subscriptionOutcome = await bootstrap.live - if (handoffEpoch !== epoch || options.sessionKey.value !== key) return + if ( + handoffEpoch !== epoch + || canonicalSessionKey(options.sessionKey.value) !== targetKey + ) return return { authoritative: subscriptionOutcome?.authoritative === true, authoritativeIdle: subscriptionOutcome?.authoritative === true @@ -276,21 +281,23 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { key: string, guard: DraftSessionRebindGuard, ): Promise { - const sourceSessionKey = options.sessionKey.value - if (!key || !guard(sourceSessionKey)) return false - const { epoch, signal: handoffSignal } = beginHandoff(key) - if (key === sourceSessionKey) { + const guardedSourceKey = options.sessionKey.value + if (!key || !guard(guardedSourceKey)) return false + const sourceKey = canonicalSessionKey(guardedSourceKey) + const targetKey = canonicalSessionKey(key) + const { epoch, signal: handoffSignal } = beginHandoff(targetKey) + if (targetKey === sourceKey) { finishHandoff(epoch, 'unchanged') return false } const shouldCommit = () => ( - isCurrentHandoff(epoch, key, sourceSessionKey) - && guard(sourceSessionKey) + isCurrentHandoff(epoch, targetKey, sourceKey) + && guard(guardedSourceKey) ) try { const pendingQueueSwitch = options.switchPendingQueue( - key, + targetKey, shouldCommit, handoffSignal, ) @@ -307,7 +314,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { resetCompactState() // A recovered provisional draft remains a draft: do not write it to the URL // or active-session storage before the first accepted send. - options.sessionKey.value = key + options.sessionKey.value = targetKey resetSessionRuntimeState() options.pendingSessionIntent.value = 'new_chat' options.applySessionRunState({ run_status: 'idle' }) @@ -320,7 +327,8 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { finishHandoff(epoch, 'committed') } const outcome = await live - return handoffEpoch === epoch && options.sessionKey.value === key + return handoffEpoch === epoch + && canonicalSessionKey(options.sessionKey.value) === targetKey ? outcome : false } @@ -328,13 +336,17 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { // Drafts keep their provisional key out of the URL and local storage; it // only persists once the first message actually goes out. async function startDraftSession(agentId?: string) { - const key = options.createSessionKey(agentId) - const sourceKey = options.sessionKey.value - const { epoch, signal: handoffSignal } = beginHandoff(key) - const shouldCommit = () => isCurrentHandoff(epoch, key, sourceKey) + const targetKey = canonicalSessionKey(options.createSessionKey(agentId)) + const sourceKey = canonicalSessionKey(options.sessionKey.value) + const { epoch, signal: handoffSignal } = beginHandoff(targetKey) + if (targetKey === sourceKey) { + finishHandoff(epoch, 'unchanged') + return + } + const shouldCommit = () => isCurrentHandoff(epoch, targetKey, sourceKey) try { const pendingQueueSwitch = options.switchPendingQueue( - key, + targetKey, shouldCommit, handoffSignal, ) @@ -350,7 +362,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { options.retireAttachments?.() options.cancelSessionBootstrap() resetCompactState() - options.sessionKey.value = key + options.sessionKey.value = targetKey resetSessionRuntimeState() // A brand-new provisional key cannot own a durable Gateway task yet. Its // first send must not wait for optional draft bootstrap metadata. From 79ac8ea7875f5ed74b32e1a1bb27537133132a00 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 00:50:20 +0800 Subject: [PATCH 05/18] Harden attachment recovery concurrency --- .../chat/useChatAttachments.test.ts | 63 ++++++++- .../composables/chat/useChatAttachments.ts | 121 ++++++++++-------- opensquilla-webui/src/views/ChatView.vue | 21 +-- .../views/chatViewSessionNavigation.test.ts | 65 ++++++++++ .../src/views/chatViewSessionNavigation.ts | 20 +++ 5 files changed, 226 insertions(+), 64 deletions(-) create mode 100644 opensquilla-webui/src/views/chatViewSessionNavigation.test.ts create mode 100644 opensquilla-webui/src/views/chatViewSessionNavigation.ts diff --git a/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts index bb51536fb..bdfd96d5b 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts @@ -346,9 +346,10 @@ describe('useChatAttachments', () => { it('does not let retired refresh cleanup release same-id work in the next session', async () => { const sessionAUpload = deferred() const sessionBUpload = deferred() - vi.stubGlobal('fetch', vi.fn() + const fetchMock = vi.fn() .mockImplementationOnce(() => sessionAUpload.promise) - .mockImplementationOnce(() => sessionBUpload.promise)) + .mockImplementationOnce(() => sessionBUpload.promise) + vi.stubGlobal('fetch', fetchMock) const attachments = useTestChatAttachments() const sessionAFile = stagedPdf('session-a-refresh.pdf') attachments.pendingAttachments.value = [{ @@ -381,6 +382,12 @@ describe('useChatAttachments', () => { expect(attachments.pendingAttachments.value).toMatchObject([ { name: 'session-b-refresh.pdf', file_uuid: 'file-b-expired' }, ]) + await expect(attachments.prepareAttachmentsForSend({ + ownership: 'composer', + attachments: attachments.pendingAttachments.value.map(attachment => ({ ...attachment })), + isCurrent: () => true, + })).resolves.toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(2) sessionBUpload.resolve(successfulUploadResponse('file-b-fresh')) await expect(preparingSessionB).resolves.toBe(true) @@ -391,6 +398,58 @@ describe('useChatAttachments', () => { expect(pushToast).not.toHaveBeenCalled() }) + it('serializes cloned composer snapshots within one attachment generation', async () => { + const firstUpload = deferred() + const releasedUpload = deferred() + const fetchMock = vi.fn() + .mockImplementationOnce(() => firstUpload.promise) + .mockImplementationOnce(() => releasedUpload.promise) + vi.stubGlobal('fetch', fetchMock) + const attachments = useTestChatAttachments() + const sourceFile = stagedPdf('composer-refresh.pdf') + const source: Attachment = { + kind: 'staged', + local_id: 1, + name: sourceFile.name, + mime: sourceFile.type, + file_uuid: 'file-expired', + expires_at: 0, + file: sourceFile, + } + const firstSnapshot = [{ ...source }] + const secondSnapshot = [{ ...source }] + + const firstPreparation = attachments.prepareAttachmentsForSend({ + ownership: 'composer', + attachments: firstSnapshot, + isCurrent: () => true, + }) + await expect(attachments.prepareAttachmentsForSend({ + ownership: 'composer', + attachments: secondSnapshot, + isCurrent: () => true, + })).resolves.toBe(false) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + firstUpload.resolve(successfulUploadResponse('file-first-fresh')) + await expect(firstPreparation).resolves.toBe(true) + expect(firstSnapshot).toMatchObject([{ file_uuid: 'file-first-fresh' }]) + expect(secondSnapshot).toMatchObject([{ file_uuid: 'file-expired' }]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + + const preparationAfterRelease = attachments.prepareAttachmentsForSend({ + ownership: 'composer', + attachments: secondSnapshot, + isCurrent: () => true, + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + releasedUpload.resolve(successfulUploadResponse('file-second-fresh')) + await expect(preparationAfterRelease).resolves.toBe(true) + expect(secondSnapshot).toMatchObject([{ file_uuid: 'file-second-fresh' }]) + expect(attachments.hasPendingAttachmentWork()).toBe(false) + }) + it('keeps a detached handoff refresh alive when the visible composer retires', async () => { const upload = deferred() vi.stubGlobal('fetch', vi.fn(() => upload.promise)) diff --git a/opensquilla-webui/src/composables/chat/useChatAttachments.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.ts index 2f1f9300f..d7773df4c 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.ts @@ -122,6 +122,10 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { const composerRefreshInFlightCount = ref(0) const attachmentIntakeInFlightCount = ref(0) let attachmentGeneration = 0 + // Composer send snapshots clone both the array and its attachment objects. + // Exclude them at the draft-generation boundary; detached queue/handoff + // collections intentionally keep the per-collection identity lane below. + let composerPreparationInFlight: { generation: number } | null = null const attachmentWorkBusy = computed(() => attachmentIntakeInFlightCount.value > 0 || composerRefreshInFlightCount.value > 0 @@ -285,6 +289,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { function retireAttachments() { attachmentGeneration += 1 refreshInFlightByCollection.delete(pendingAttachments.value) + composerPreparationInFlight = null pendingAttachments.value = [] composerRefreshInFlightCount.value = 0 attachmentIntakeInFlightCount.value = 0 @@ -339,68 +344,80 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { (!composerOwned || isAttachmentGenerationCurrent(generation)) && isCurrent() ) const attachments = options.attachments ?? pendingAttachments.value + const composerPreparation = composerOwned ? { generation } : null + if ( + composerPreparation + && composerPreparationInFlight?.generation === generation + ) return false + if (composerPreparation) composerPreparationInFlight = composerPreparation let refreshInFlightAttachments = refreshInFlightByCollection.get(attachments) if (!refreshInFlightAttachments) { refreshInFlightAttachments = new Set() refreshInFlightByCollection.set(attachments, refreshInFlightAttachments) } - const staged = [...attachments].filter(stagedUploadNeedsRefresh) - for (const attachment of staged) { - if (!preparationIsCurrent()) return false - if (refreshInFlightAttachments.has(attachment)) return false - const idx = attachments.indexOf(attachment) - if (idx < 0 || attachments[idx].kind !== 'staged') continue - if (!attachment.file) { - attachments[idx] = { - kind: 'failed', - local_id: attachment.local_id, - name: attachment.name, - mime: attachment.mime, - size: attachment.size, - error: 'Upload expired; select the file again', - } - pushToast(`Upload expired for ${attachment.name}: select the file again`, { tone: 'danger' }) - return false - } - refreshInFlightAttachments.add(attachment) - if (composerOwned) composerRefreshInFlightCount.value += 1 - try { - const meta = await uploadAttachmentFile(attachment.file, attachment.mime) + try { + const staged = [...attachments].filter(stagedUploadNeedsRefresh) + for (const attachment of staged) { if (!preparationIsCurrent()) return false - const currentIdx = attachments.indexOf(attachment) - if (currentIdx < 0 || attachments[currentIdx].kind !== 'staged') continue - attachments[currentIdx] = { - kind: 'staged', - local_id: attachment.local_id, - name: attachment.name, - mime: attachment.mime, - size: attachment.size, - file_uuid: meta.fileUuid, - expires_at: meta.expiresAt, - ttl_seconds: meta.ttlSeconds, - file: attachment.file, + if (refreshInFlightAttachments.has(attachment)) return false + const idx = attachments.indexOf(attachment) + if (idx < 0 || attachments[idx].kind !== 'staged') continue + if (!attachment.file) { + attachments[idx] = { + kind: 'failed', + local_id: attachment.local_id, + name: attachment.name, + mime: attachment.mime, + size: attachment.size, + error: 'Upload expired; select the file again', + } + pushToast(`Upload expired for ${attachment.name}: select the file again`, { tone: 'danger' }) + return false } - } catch (err: unknown) { - if (!preparationIsCurrent()) return false - const message = uploadFailureMessage(err) - markAttachmentFailed( - attachment.local_id, - attachment.file, - attachment.mime, - message, - attachments, - attachment, - ) - pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: attachment.name })}: ${message}`, { tone: 'danger' }) - return false - } finally { - const removed = refreshInFlightAttachments.delete(attachment) - if (composerOwned && removed && isAttachmentGenerationCurrent(generation)) { - composerRefreshInFlightCount.value = Math.max(0, composerRefreshInFlightCount.value - 1) + refreshInFlightAttachments.add(attachment) + if (composerOwned) composerRefreshInFlightCount.value += 1 + try { + const meta = await uploadAttachmentFile(attachment.file, attachment.mime) + if (!preparationIsCurrent()) return false + const currentIdx = attachments.indexOf(attachment) + if (currentIdx < 0 || attachments[currentIdx].kind !== 'staged') continue + attachments[currentIdx] = { + kind: 'staged', + local_id: attachment.local_id, + name: attachment.name, + mime: attachment.mime, + size: attachment.size, + file_uuid: meta.fileUuid, + expires_at: meta.expiresAt, + ttl_seconds: meta.ttlSeconds, + file: attachment.file, + } + } catch (err: unknown) { + if (!preparationIsCurrent()) return false + const message = uploadFailureMessage(err) + markAttachmentFailed( + attachment.local_id, + attachment.file, + attachment.mime, + message, + attachments, + attachment, + ) + pushToast(`${i18n.global.t('chat.toast.uploadFailed', { name: attachment.name })}: ${message}`, { tone: 'danger' }) + return false + } finally { + const removed = refreshInFlightAttachments.delete(attachment) + if (composerOwned && removed && isAttachmentGenerationCurrent(generation)) { + composerRefreshInFlightCount.value = Math.max(0, composerRefreshInFlightCount.value - 1) + } } } + return true + } finally { + if (composerPreparationInFlight === composerPreparation) { + composerPreparationInFlight = null + } } - return true } function activeAttachmentCount(): number { diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index c0c406ab0..ad82a6e64 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -942,6 +942,7 @@ import { optionalSessionRpcCallOptions, } from '@/composables/chat/sessionBootstrapAdmission' import { useChatSessionRuntime } from '@/composables/chat/useChatSessionRuntime' +import { switchChatViewSession } from '@/views/chatViewSessionNavigation' import { useChatSessionSubscription, type SessionSubscriptionOutcome, @@ -2931,19 +2932,19 @@ const { switchToPlanSession = switchToSession async function switchToSession(nextSessionKey: string) { - const outcome = await switchRuntimeToSession(nextSessionKey) - if (outcome?.authoritative) { - await handleAuthoritativeSessionSubscription(nextSessionKey) - } - return outcome + return switchChatViewSession( + nextSessionKey, + switchRuntimeToSession, + handleAuthoritativeSessionSubscription, + ) } async function adoptMaterializedSession(nextSessionKey: string) { - const outcome = await adoptRuntimeMaterializedSession(nextSessionKey) - if (outcome?.authoritative) { - await handleAuthoritativeSessionSubscription(nextSessionKey) - } - return outcome + return switchChatViewSession( + nextSessionKey, + adoptRuntimeMaterializedSession, + handleAuthoritativeSessionSubscription, + ) } const metaSkillSetup = useMetaSkillSetup({ diff --git a/opensquilla-webui/src/views/chatViewSessionNavigation.test.ts b/opensquilla-webui/src/views/chatViewSessionNavigation.test.ts new file mode 100644 index 000000000..c6e45a741 --- /dev/null +++ b/opensquilla-webui/src/views/chatViewSessionNavigation.test.ts @@ -0,0 +1,65 @@ +import { ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import { switchChatViewSession } from './chatViewSessionNavigation' + +const AUTHORITATIVE_OUTCOME = { + authoritative: true, + authoritativeIdle: true, + backgroundOnly: false, +} + +describe('ChatView post-subscription session recovery', () => { + it('recovers an alias navigation against the canonical mounted session key', async () => { + const sessionKey = ref('agent:main:webchat:source') + const recoveredSessionKeys: string[] = [] + const switchSession = vi.fn(async () => { + sessionKey.value = 'agent:main:webchat:target' + return AUTHORITATIVE_OUTCOME + }) + const handleAuthoritativeSubscription = vi.fn(async (targetSessionKey: string) => { + if (sessionKey.value !== targetSessionKey) return + recoveredSessionKeys.push(targetSessionKey) + }) + + await switchChatViewSession( + 'sess-target', + switchSession, + handleAuthoritativeSubscription, + ) + + expect(switchSession).toHaveBeenCalledWith('sess-target') + expect(handleAuthoritativeSubscription).toHaveBeenCalledWith( + 'agent:main:webchat:target', + ) + expect(recoveredSessionKeys).toEqual(['agent:main:webchat:target']) + }) + + it('retains the completed navigation key so a superseding session stays guarded', async () => { + const sessionKey = ref('agent:main:webchat:source') + const recoveredSessionKeys: string[] = [] + let finishSwitch!: (value: typeof AUTHORITATIVE_OUTCOME) => void + const switchSession = vi.fn(() => new Promise(resolve => { + finishSwitch = resolve + })) + const handleAuthoritativeSubscription = vi.fn(async (targetSessionKey: string) => { + if (sessionKey.value !== targetSessionKey) return + recoveredSessionKeys.push(targetSessionKey) + }) + + const switching = switchChatViewSession( + 'sess-target', + switchSession, + handleAuthoritativeSubscription, + ) + sessionKey.value = 'agent:main:webchat:target' + finishSwitch(AUTHORITATIVE_OUTCOME) + sessionKey.value = 'agent:main:webchat:newer' + await switching + + expect(handleAuthoritativeSubscription).toHaveBeenCalledWith( + 'agent:main:webchat:target', + ) + expect(recoveredSessionKeys).toEqual([]) + }) +}) diff --git a/opensquilla-webui/src/views/chatViewSessionNavigation.ts b/opensquilla-webui/src/views/chatViewSessionNavigation.ts new file mode 100644 index 000000000..21f9e9049 --- /dev/null +++ b/opensquilla-webui/src/views/chatViewSessionNavigation.ts @@ -0,0 +1,20 @@ +import { canonicalSessionKey } from '@/utils/chat/sessionKeys' + +interface AuthoritativeSessionSwitchResult { + authoritative: boolean +} + +export async function switchChatViewSession< + Result extends AuthoritativeSessionSwitchResult, +>( + requestedSessionKey: string, + switchSession: (sessionKey: string) => Promise, + onAuthoritativeSubscription: (sessionKey: string) => void | Promise, +): Promise { + const mountedSessionKey = canonicalSessionKey(requestedSessionKey) + const outcome = await switchSession(requestedSessionKey) + if (outcome?.authoritative) { + await onAuthoritativeSubscription(mountedSessionKey) + } + return outcome +} From a946118b95b53a00360e07c384246814a2c94836 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 17:10:43 +0800 Subject: [PATCH 06/18] Update attachment E2E for session reads --- .../e2e/attachment-drag-upload.spec.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/opensquilla-webui/e2e/attachment-drag-upload.spec.ts b/opensquilla-webui/e2e/attachment-drag-upload.spec.ts index 3680f251c..74148dece 100644 --- a/opensquilla-webui/e2e/attachment-drag-upload.spec.ts +++ b/opensquilla-webui/e2e/attachment-drag-upload.spec.ts @@ -1,4 +1,10 @@ import { expect, test, type Download, type Page } from '@playwright/test' +import { + chatHistoryPayload, + sessionMessagesHydratePayload, + sessionMessagesSnapshotPayload, + sessionMessagesSubscribePayload, +} from './support/session-read-fixtures' const CONTROL_URL = '/control/chat/new' const HISTORY_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' @@ -42,10 +48,21 @@ async function mockRpc(page: Page, capturedSends: CapturedSend[], options: MockR ws.onMessage(message => { try { const frame = JSON.parse(String(message)) + if (frame?.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })) + return + } if (frame?.type !== 'req') return const method = String(frame.method || '') + const sessionKey = String(frame.params?.key || frame.params?.sessionKey || '') if (method === 'connect') { - ws.send(JSON.stringify({ protocol: 3, policy: { tick_interval_ms: 30000 } })) + ws.send(JSON.stringify({ + protocol: 3, + policy: { + tick_interval_ms: 30000, + concurrent_history_reads: true, + }, + })) return } if (method === 'chat.send') { @@ -78,7 +95,7 @@ async function mockRpc(page: Page, capturedSends: CapturedSend[], options: MockR } if (method === 'chat.history') { options.historyRequests?.push(frame.params || {}) - ws.send(wsResponse(String(frame.id), { messages: historyMessages, has_more: false })) + ws.send(wsResponse(String(frame.id), chatHistoryPayload(historyMessages))) return } @@ -91,12 +108,9 @@ async function mockRpc(page: Page, capturedSends: CapturedSend[], options: MockR skills: {}, }, 'sessions.list': { sessions: [], has_more: false }, - 'sessions.messages.subscribe': { - subscribed: true, - replay_complete: true, - current_stream_seq: 0, - run_status: 'idle', - }, + 'sessions.messages.snapshot': sessionMessagesSnapshotPayload(sessionKey), + 'sessions.messages.subscribe': sessionMessagesSubscribePayload(sessionKey), + 'sessions.messages.hydrate': sessionMessagesHydratePayload(sessionKey), 'usage.status': { sessions: [] }, } From 11a034eabd74b8767941ec77c628843a61dd8cd1 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 17:27:27 +0800 Subject: [PATCH 07/18] Fence draft recovery across session changes --- .../chat/useChatPendingQueue.test.ts | 101 +++++++++++++ .../composables/chat/useChatPendingQueue.ts | 44 ++++-- .../ChatView.meta-draft-recovery.test.ts | 135 ++++++++++++++++++ opensquilla-webui/src/views/ChatView.vue | 1 + 4 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 opensquilla-webui/src/views/ChatView.meta-draft-recovery.test.ts diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 853944a80..866dfc30c 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -1132,6 +1132,107 @@ describe('useChatPendingQueue delivery state', () => { queue.cleanup() }) + it.each([ + 'editPendingItem', + 'popPendingTail', + 'popAllPendingIntoComposer', + ] as const)( + 'keeps an attachment draft parked in A when %s cancellation settles after navigating to B', + async recoveryPath => { + const sessionA = 'agent:main:webchat:test' + const sessionB = 'agent:main:webchat:B' + const { wal, records } = memoryWal() + const writeWal = wal.put + let releaseCancel!: () => void + let markCancelStarted!: () => void + const cancelStarted = new Promise(resolve => { markCancelStarted = resolve }) + const cancelGate = new Promise(resolve => { releaseCancel = resolve }) + wal.put = vi.fn(async record => { + if (record.state === 'cancelling') { + markCancelStarted() + await cancelGate + } + await writeWal(record) + }) + const { + inputText, + pendingAttachments, + pendingSessionIntent, + queue, + sessionKey, + } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + const sourceAttachment: Attachment = { + kind: 'staged', + local_id: 301, + name: 'source-a.txt', + mime: 'text/plain', + size: 8, + file_uuid: 'source-a-upload', + } + const targetAttachment: Attachment = { + kind: 'staged', + local_id: 302, + name: 'target-b.txt', + mime: 'text/plain', + size: 8, + file_uuid: 'target-b-upload', + } + try { + inputText.value = 'source A draft' + pendingAttachments.value = [sourceAttachment] + pendingSessionIntent.value = 'intent:A' + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('local_only') + }) + const itemId = pendingUiId(queue, 0) + + const started = recoveryPath === 'editPendingItem' + ? queue.editPendingItem(itemId) + : recoveryPath === 'popPendingTail' + ? queue.popPendingTail() + : queue.popAllPendingIntoComposer() + expect(started).toBe(true) + await cancelStarted + + queue.switchPendingQueue(sessionB) + sessionKey.value = sessionB + inputText.value = 'target B draft' + pendingAttachments.value = [targetAttachment] + pendingSessionIntent.value = 'intent:B' + releaseCancel() + + await vi.waitFor(() => { + expect([...records.values()][0]).toMatchObject({ + sessionKey: sessionA, + state: 'local_only', + retainAfterCancel: true, + }) + }) + expect(inputText.value).toBe('target B draft') + expect(pendingAttachments.value).toEqual([targetAttachment]) + expect(pendingSessionIntent.value).toBe('intent:B') + expect(queue.pendingQueue.value).toEqual([]) + + queue.switchPendingQueue(sessionA) + sessionKey.value = sessionA + await nextTick() + expect(queue.pendingQueue.value).toHaveLength(1) + expect(queue.pendingQueue.value[0]).toMatchObject({ + text: 'source A draft', + ownerSessionKey: sessionA, + pendingPersistenceState: 'local_only', + attachments: [{ name: 'source-a.txt' }], + }) + } finally { + queue.cleanup() + } + }, + ) + it('does not edit a queued annotation batch into plain text', async () => { const { inputText, queue } = makeQueue() await expect(queue.enqueuePendingPayload({ diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 23f16a2a4..67647c6c9 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -163,6 +163,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const pendingInputQueue = options.pendingInputQueue const pendingQueue = ref([]) const parkedQueues = new Map() + let activeQueueLease = 0 let pendingDrainTimer: ReturnType | null = null let deferredDrainRequested = false const isReordering = ref(false) @@ -1330,6 +1331,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (!shouldCommit()) return cancelPendingReorder() clearPendingDrainAfterTerminalTimer() + activeQueueLease += 1 const sourceSessionKey = options.sessionKey.value if (sourceSessionKey && pendingQueue.value.length > 0) { const existing = parkedQueues.get(sourceSessionKey) || [] @@ -1417,6 +1419,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const committed = await acceptDurableHandoff(targetSessionKey, ownerRequestId) if (!committed) return if (options.sessionKey.value === targetSessionKey) { + activeQueueLease += 1 const restored = parkedQueues.get(targetSessionKey) || [] parkedQueues.delete(targetSessionKey) pendingQueue.value = [...pendingQueue.value, ...restored] @@ -1461,6 +1464,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { // handoff has committed and this epoch is current. Clearing it before the // await would strand A if IndexedDB failed or A→B was superseded by A. clearPendingDrainAfterTerminalTimer() + activeQueueLease += 1 const carried: ChatPendingItem[] = [] const stayingVisible: ChatPendingItem[] = [] const stayingHidden: ChatPendingItem[] = [] @@ -1517,6 +1521,29 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { )) } + function restoreDurableItemIntoComposer( + item: ChatPendingItem, + restore: () => void, + ) { + const ownerSessionKey = item.ownerSessionKey || options.sessionKey.value + const queueLease = activeQueueLease + void cancelDurableItem(item, { retainAfterCancel: true }).then(retained => { + if ( + !retained + || options.sessionKey.value !== ownerSessionKey + || activeQueueLease !== queueLease + ) return + const index = pendingQueue.value.indexOf(item) + if (index < 0) return + pendingQueue.value.splice(index, 1) + restore() + // The composer now owns the retained local-only payload. Remove its WAL + // record without reopening a window where a navigation can restore the + // source item into a different session's composer. + void cancelDurableItem(item) + }) + } + function editPendingItem(pendingUiId: string): boolean { const index = pendingIndex(pendingUiId) const item = pendingQueue.value[index] @@ -1555,12 +1582,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { options.autoResizeTextarea() } if (durableItem(item)) { - void cancelDurableItem(item).then(cancelled => { - if (!cancelled) return - const currentIndex = pendingQueue.value.indexOf(item) - if (currentIndex >= 0) pendingQueue.value.splice(currentIndex, 1) - restore() - }) + restoreDurableItemIntoComposer(item, restore) return true } pendingQueue.value.splice(index, 1) @@ -1586,10 +1608,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (!tail) return false if (hasUneditablePendingAttachments(tail)) return false if (durableItem(tail)) { - void cancelDurableItem(tail).then(cancelled => { - if (!cancelled) return - const index = pendingQueue.value.indexOf(tail) - if (index >= 0) pendingQueue.value.splice(index, 1) + restoreDurableItemIntoComposer(tail, () => { options.inputText.value = tail.text || '' options.pendingAttachments.value = tail.attachments || [] options.pendingSessionIntent.value = tail.intent || null @@ -1638,10 +1657,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { options.autoResizeTextarea() options.resetInputHistory() for (const item of durable) { - void cancelDurableItem(item).then(cancelled => { - if (!cancelled) return - const index = pendingQueue.value.indexOf(item) - if (index >= 0) pendingQueue.value.splice(index, 1) + restoreDurableItemIntoComposer(item, () => { options.inputText.value = [options.inputText.value, item.text] .filter(Boolean) .join('\n') diff --git a/opensquilla-webui/src/views/ChatView.meta-draft-recovery.test.ts b/opensquilla-webui/src/views/ChatView.meta-draft-recovery.test.ts new file mode 100644 index 000000000..65291a382 --- /dev/null +++ b/opensquilla-webui/src/views/ChatView.meta-draft-recovery.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useChatAttachments } from '@/composables/chat/useChatAttachments' +import { + createChatMetaDraftRecovery, + type MetaDraftListResult, +} from '@/composables/chat/useChatMetaDraftRecovery' +import type { DurableMetaDraft } from '@/composables/chat/useChatSlashCommands' +import chatViewSource from './ChatView.vue?raw' + +const pushToast = vi.hoisted(() => vi.fn()) + +vi.mock('@/composables/useToasts', () => ({ + useToasts: () => ({ pushToast }), +})) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +class ControlledFileReader { + static instances: ControlledFileReader[] = [] + + result: string | ArrayBuffer | null = null + onload: ((event: ProgressEvent) => void) | null = null + onerror: ((event: ProgressEvent) => void) | null = null + + readAsDataURL(_blob: Blob) { + ControlledFileReader.instances.push(this) + } + + succeed(dataUrl: string) { + this.result = dataUrl + this.onload?.({ target: this } as unknown as ProgressEvent) + } +} + +function serverDraft(sessionKey: string): DurableMetaDraft { + return { + sessionKey, + clientRequestId: 'request-meta-recovery', + name: 'meta-draft-recovery', + launchText: '/meta run meta-draft-recovery', + createdAt: 1, + expiresAt: 2, + sessionExists: false, + } +} + +describe('ChatView Meta draft attachment recovery fence', () => { + afterEach(() => { + ControlledFileReader.instances = [] + pushToast.mockClear() + vi.unstubAllGlobals() + }) + + it('includes in-flight attachment work in the pristine draft boundary', () => { + const start = chatViewSource.indexOf('function isPristineDraftForRecovery(') + const end = chatViewSource.indexOf('\nconst metaDraftRecovery =', start) + const source = chatViewSource.slice(start, end) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(source).toContain('&& !attachmentWorkBusy.value') + expect(source.indexOf('!attachmentWorkBusy.value')).toBeLessThan( + source.indexOf('pendingAttachments.value.length === 0'), + ) + }) + + it('rejects Meta rebind while unknown MIME sniffing keeps the file on its source draft', async () => { + vi.stubGlobal('FileReader', ControlledFileReader) + const attachments = useChatAttachments() + const discovery = deferred() + const sniff = deferred() + const sourceSessionKey = 'agent:main:webchat:local-draft' + const recoveredSessionKey = 'agent:main:webchat:server-draft' + let currentSessionKey = sourceSessionKey + const pristine = vi.fn((sessionKey: string) => ( + sessionKey === currentSessionKey + && !attachments.attachmentWorkBusy.value + && attachments.pendingAttachments.value.length === 0 + )) + const rebindDraftSession = vi.fn(async (sessionKey: string) => { + currentSessionKey = sessionKey + return { authoritative: true, live: false, backgroundOnly: false } + }) + const restore = vi.fn() + const recovery = createChatMetaDraftRecovery({ + currentSessionKey: () => currentSessionKey, + listDrafts: () => discovery.promise, + isPristineDraft: pristine, + rebindDraftSession, + onAuthoritativeSubscription: restore, + }) + + recovery.start('main') + + const file = new File( + ['source draft attachment'], + 'source-draft.unknown', + { type: 'application/x-unknown' }, + ) + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn(() => sniff.promise), + }) + const adding = attachments.addAttachment(file) + + expect(attachments.pendingAttachments.value).toEqual([]) + expect(attachments.attachmentWorkBusy.value).toBe(true) + + discovery.resolve({ drafts: [serverDraft(recoveredSessionKey)], retryable: false }) + await vi.waitFor(() => expect(pristine).toHaveBeenCalledTimes(2)) + + expect(rebindDraftSession).not.toHaveBeenCalled() + expect(restore).not.toHaveBeenCalled() + expect(currentSessionKey).toBe(sourceSessionKey) + + sniff.resolve(new TextEncoder().encode('source draft attachment').buffer) + await adding + expect(ControlledFileReader.instances).toHaveLength(1) + ControlledFileReader.instances[0]!.succeed( + 'data:text/plain;base64,c291cmNlIGRyYWZ0IGF0dGFjaG1lbnQ=', + ) + + expect(currentSessionKey).toBe(sourceSessionKey) + expect(attachments.pendingAttachments.value).toMatchObject([ + { kind: 'inline', name: 'source-draft.unknown' }, + ]) + expect(attachments.attachmentWorkBusy.value).toBe(false) + expect(pushToast).not.toHaveBeenCalled() + }) +}) diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index 6c3ed7e6e..679b89e98 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -3599,6 +3599,7 @@ function isPristineDraftForRecovery(expectedSessionKey: string, agentId: string) && pendingSessionIntent.value === 'new_chat' && messages.value.length === 0 && inputText.value.length === 0 + && !attachmentWorkBusy.value && pendingAttachments.value.length === 0 && pendingQueue.value.length === 0 && pendingAutoSend.value.length === 0 From cab6a0d08486e751800054509e454d8ea3dcc8be Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 17:45:20 +0800 Subject: [PATCH 08/18] Fence pending draft recovery ownership --- .../chat/useChatPendingQueue.test.ts | 189 +++++++++++++++ .../composables/chat/useChatPendingQueue.ts | 225 ++++++++++++------ .../chat/useChatSessionRuntime.test.ts | 89 ++++++- 3 files changed, 430 insertions(+), 73 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 866dfc30c..586d286d2 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -1233,6 +1233,195 @@ describe('useChatPendingQueue delivery state', () => { }, ) + it('keeps a retained queue item when the composer changes during delayed recovery', async () => { + const { wal, records } = memoryWal() + const writeWal = wal.put + let releaseCancel!: () => void + let markCancelStarted!: () => void + const cancelStarted = new Promise(resolve => { markCancelStarted = resolve }) + const cancelGate = new Promise(resolve => { releaseCancel = resolve }) + wal.put = vi.fn(async record => { + if (record.state === 'cancelling') { + markCancelStarted() + await cancelGate + } + await writeWal(record) + }) + const { + inputText, + pendingAttachments, + pendingSessionIntent, + queue, + } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + const sourceAttachment: Attachment = { + kind: 'staged', + local_id: 311, + name: 'queued-source.txt', + mime: 'text/plain', + file_uuid: 'queued-source-upload', + } + const newAttachment: Attachment = { + kind: 'staged', + local_id: 312, + name: 'new-composer.txt', + mime: 'text/plain', + file_uuid: 'new-composer-upload', + } + try { + inputText.value = 'queued source draft' + pendingAttachments.value = [sourceAttachment] + pendingSessionIntent.value = 'intent:queued' + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('local_only') + }) + + expect(queue.popPendingTail()).toBe(true) + await cancelStarted + inputText.value = 'new composer draft' + pendingAttachments.value = [newAttachment] + pendingSessionIntent.value = 'intent:new' + releaseCancel() + + await vi.waitFor(() => { + expect([...records.values()][0]).toMatchObject({ + state: 'local_only', + retainAfterCancel: true, + }) + }) + expect(inputText.value).toBe('new composer draft') + expect(pendingAttachments.value).toEqual([newAttachment]) + expect(pendingSessionIntent.value).toBe('intent:new') + expect(queue.pendingQueue.value).toMatchObject([{ + text: 'queued source draft', + pendingPersistenceState: 'local_only', + attachments: [{ name: 'queued-source.txt' }], + }]) + } finally { + queue.cleanup() + } + }) + + it('retains delayed composer recovery in the WAL after cleanup for the next hydrate', async () => { + const { wal, records } = memoryWal() + const writeWal = wal.put + let releaseCancel!: () => void + let markCancelStarted!: () => void + const cancelStarted = new Promise(resolve => { markCancelStarted = resolve }) + const cancelGate = new Promise(resolve => { releaseCancel = resolve }) + wal.put = vi.fn(async record => { + if (record.state === 'cancelling') { + markCancelStarted() + await cancelGate + } + await writeWal(record) + }) + const initial = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + initial.inputText.value = 'survive cleanup' + initial.pendingAttachments.value = [{ + kind: 'staged', + local_id: 321, + name: 'survive-cleanup.txt', + mime: 'text/plain', + file_uuid: 'survive-cleanup-upload', + }] + await expect(initial.queue.enqueuePendingInput(initial.inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(initial.queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('local_only') + }) + + expect(initial.queue.editPendingItem(pendingUiId(initial.queue, 0))).toBe(true) + await cancelStarted + initial.queue.cleanup() + releaseCancel() + await vi.waitFor(() => { + expect([...records.values()][0]).toMatchObject({ + state: 'local_only', + retainAfterCancel: true, + attachments: [{ name: 'survive-cleanup.txt' }], + }) + }) + expect(initial.inputText.value).toBe('') + expect(initial.pendingAttachments.value).toEqual([]) + + const restored = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await vi.waitFor(() => { + expect(restored.queue.pendingQueue.value).toMatchObject([{ + text: 'survive cleanup', + pendingPersistenceState: 'local_only', + attachments: [{ name: 'survive-cleanup.txt' }], + }]) + }) + expect(restored.inputText.value).toBe('') + expect(restored.pendingAttachments.value).toEqual([]) + } finally { + restored.queue.cleanup() + } + }) + + it('hydrates and parks a legacy alias WAL row under its canonical queue owner', async () => { + const legacySession = 'agent:default:webchat:alias-draft' + const canonicalSession = 'agent:main:webchat:alias-draft' + const { wal } = memoryWal([{ + schemaVersion: 1, + pendingInputId: 'pending-alias-draft', + sessionKey: legacySession, + clientRequestId: 'request-alias-draft', + clientMessageId: 'message-alias-draft', + text: 'legacy alias attachment draft', + attachments: [{ + kind: 'staged', + local_id: 331, + name: 'legacy-alias.txt', + mime: 'text/plain', + file_uuid: 'legacy-alias-upload', + }], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + createdAt: 1, + updatedAt: 2, + }]) + const sessionKey = ref(canonicalSession) + const { queue } = makeQueue(undefined, () => false, undefined, undefined, { + sessionKey, + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await vi.waitFor(() => { + expect(queue.pendingQueue.value).toMatchObject([{ + pendingInputId: 'pending-alias-draft', + ownerSessionKey: canonicalSession, + attachments: [{ name: 'legacy-alias.txt' }], + }]) + }) + + queue.switchPendingQueue('agent:main:webchat:other') + sessionKey.value = 'agent:main:webchat:other' + expect(queue.pendingQueue.value).toEqual([]) + + queue.switchPendingQueue(canonicalSession) + sessionKey.value = canonicalSession + expect(queue.pendingQueue.value).toMatchObject([{ + pendingInputId: 'pending-alias-draft', + ownerSessionKey: canonicalSession, + }]) + } finally { + queue.cleanup() + } + }) + it('does not edit a queued annotation batch into plain text', async () => { const { inputText, queue } = makeQueue() await expect(queue.enqueuePendingPayload({ diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 67647c6c9..666f8e426 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -13,6 +13,7 @@ import { isSendableAttachment, serializeSendableAttachment, } from '@/utils/chat/attachments' +import { canonicalSessionKey } from '@/utils/chat/sessionKeys' import type { AcceptedHandoffCommit, PendingInputWal, @@ -160,10 +161,39 @@ export interface UseChatPendingQueueOptions { } export function useChatPendingQueue(options: UseChatPendingQueueOptions) { + const queueSessionKey = (key = options.sessionKey.value) => { + const value = String(key || '').trim() + return value ? canonicalSessionKey(value) : '' + } + const walLookupSessionKeys = (key: string) => { + const canonicalKey = queueSessionKey(key) + const keys = new Set([canonicalKey]) + const rawKey = String(key || '').trim() + if (rawKey && queueSessionKey(rawKey) === canonicalKey) keys.add(rawKey) + if (canonicalKey.startsWith('agent:main:')) { + keys.add(`agent:default:${canonicalKey.slice('agent:main:'.length)}`) + } + const webchatPrefix = 'agent:main:webchat:' + if (canonicalKey.startsWith(webchatPrefix)) { + keys.add(`sess-${canonicalKey.slice(webchatPrefix.length)}`) + } + if (canonicalKey === 'agent:main:webchat:default') { + keys.add('default') + keys.add('webchat:default') + } + keys.delete('') + return [...keys] + } const pendingInputQueue = options.pendingInputQueue const pendingQueue = ref([]) const parkedQueues = new Map() let activeQueueLease = 0 + let composerRevision = 0 + const stopComposerRevisionWatch = watch( + [options.inputText, options.pendingAttachments, options.pendingSessionIntent], + () => { composerRevision += 1 }, + { deep: true, flush: 'sync' }, + ) let pendingDrainTimer: ReturnType | null = null let deferredDrainRequested = false const isReordering = ref(false) @@ -262,7 +292,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { return { schemaVersion: 1, pendingInputId: item.pendingInputId!, - sessionKey: item.ownerSessionKey || options.sessionKey.value, + sessionKey: queueSessionKey(item.ownerSessionKey), clientRequestId: item.pendingClientRequestId!, clientMessageId: item.pendingClientMessageId!, text: item.text, @@ -309,7 +339,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { attachments: record.attachments.map(attachment => ({ ...attachment })), intent: record.intent, ...(record.confirmedPlainText ? { confirmedPlainText: true } : {}), - ownerSessionKey: record.sessionKey, + ownerSessionKey: queueSessionKey(record.sessionKey), ...(record.ownerRequestId ? { ownerRequestId: record.ownerRequestId } : {}), pendingInputId: record.pendingInputId, pendingClientRequestId: record.clientRequestId, @@ -332,7 +362,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } function removedIdentity(sessionKey: string, pendingInputId: string): string { - return `${sessionKey}\u0000${pendingInputId}` + return `${queueSessionKey(sessionKey)}\u0000${pendingInputId}` } function rememberRemoval(sessionKey: string, pendingInputId: string) { @@ -355,23 +385,24 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } function removePendingIdentity(sessionKey: string, pendingInputId: string) { - if (options.sessionKey.value === sessionKey) { + const ownerSessionKey = queueSessionKey(sessionKey) + if (queueSessionKey() === ownerSessionKey) { pendingQueue.value = pendingQueue.value.filter(item => ( item.pendingInputId !== pendingInputId )) } - const parked = parkedQueues.get(sessionKey) + const parked = parkedQueues.get(ownerSessionKey) if (parked) { const retained = parked.filter(item => item.pendingInputId !== pendingInputId) - if (retained.length > 0) parkedQueues.set(sessionKey, retained) - else parkedQueues.delete(sessionKey) + if (retained.length > 0) parkedQueues.set(ownerSessionKey, retained) + else parkedQueues.delete(ownerSessionKey) } } async function cancelServerIdentity(sessionKey: string, pendingInputId: string) { if (!supportsServerQueue()) return await pendingInputQueue!.cancel({ - key: sessionKey, + key: queueSessionKey(sessionKey), pendingInputId, }) } @@ -382,7 +413,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { action: 'changed' | 'removed' = 'changed', ) { try { - broadcast?.postMessage({ sessionKey, pendingInputId, action }) + broadcast?.postMessage({ + sessionKey: queueSessionKey(sessionKey), + pendingInputId, + action, + }) } catch {} } @@ -467,7 +502,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { item.pendingPersistenceState === 'cancelling' || item.pendingRetainAfterCancel === true ) return - const sessionKey = item.ownerSessionKey || options.sessionKey.value + const sessionKey = queueSessionKey(item.ownerSessionKey) if (wasRemoved(sessionKey, pendingInputId)) return const existing = stagingOperations.get(pendingInputId) if (existing) return existing @@ -517,7 +552,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { item.pendingMayHaveServerCopy = true await writeWalItem(item, 'saving') const response = await pendingInputQueue!.enqueue({ - key: item.ownerSessionKey || options.sessionKey.value, + key: queueSessionKey(item.ownerSessionKey), pendingInputId, clientRequestId: item.pendingClientRequestId, clientMessageId: item.pendingClientMessageId, @@ -646,28 +681,44 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { async function hydratePendingQueue(sessionKey = options.sessionKey.value): Promise { const wal = options.pendingInputWal - if (!wal || !sessionKey || disposed) return + const ownerSessionKey = queueSessionKey(sessionKey) + if (!wal || !ownerSessionKey || disposed) return if (isReordering.value) { - deferredHydrateSession = sessionKey + deferredHydrateSession = ownerSessionKey return } const generation = ++hydrateGeneration let records: PendingInputWalRecord[] try { - records = await wal.list(sessionKey) + const recordsById = new Map() + const lookupResults = await Promise.all( + walLookupSessionKeys(sessionKey).map(lookupKey => wal.list(lookupKey)), + ) + for (const record of lookupResults.flat()) { + if (queueSessionKey(record.sessionKey) !== ownerSessionKey) continue + recordsById.set(record.pendingInputId, { + ...record, + sessionKey: ownerSessionKey, + }) + } + records = [...recordsById.values()] } catch { options.onPendingPersistenceError?.('wal_failed') return } - if (disposed || generation !== hydrateGeneration || options.sessionKey.value !== sessionKey) { + if ( + disposed + || generation !== hydrateGeneration + || queueSessionKey() !== ownerSessionKey + ) { return } - mergeWalRecords(records, sessionKey) + mergeWalRecords(records, ownerSessionKey) const walIds = new Set(records.map(record => record.pendingInputId)) if (!supportsServerQueue()) { for (const item of [...pendingQueue.value]) { - if (!durableItem(item) || item.ownerSessionKey !== sessionKey) continue + if (!durableItem(item) || queueSessionKey(item.ownerSessionKey) !== ownerSessionKey) continue const pendingInputId = item.pendingInputId! // A snapshot can race the WAL write and the enqueue itself. Keep an // in-flight/saving row visible until its owner settles; otherwise an @@ -680,8 +731,8 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { && !saving && !stagingInFlight ) { - rememberRemoval(sessionKey, pendingInputId) - removePendingIdentity(sessionKey, pendingInputId) + rememberRemoval(ownerSessionKey, pendingInputId) + removePendingIdentity(ownerSessionKey, pendingInputId) continue } // A cancellation WAL is a durable delete intent, never a draft to @@ -704,14 +755,18 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { for (const item of [...pendingQueue.value]) { if ( durableItem(item) - && item.ownerSessionKey === sessionKey + && queueSessionKey(item.ownerSessionKey) === ownerSessionKey && item.pendingPersistenceState === 'cancelling' ) void retryCancellingItem(item) } try { - const response = { items: await pendingInputQueue!.list(sessionKey) } - if (disposed || generation !== hydrateGeneration || options.sessionKey.value !== sessionKey) { + const response = { items: await pendingInputQueue!.list(ownerSessionKey) } + if ( + disposed + || generation !== hydrateGeneration + || queueSessionKey() !== ownerSessionKey + ) { return } const serverItems = Array.isArray(response.items) ? response.items : [] @@ -720,8 +775,8 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const pendingInputId = serverItem.pendingInputId const clientRequestId = serverItem.clientRequestId const clientMessageId = serverItem.clientMessageId - if (wasRemoved(sessionKey, pendingInputId)) { - void cancelServerIdentity(sessionKey, pendingInputId).catch(() => {}) + if (wasRemoved(ownerSessionKey, pendingInputId)) { + void cancelServerIdentity(ownerSessionKey, pendingInputId).catch(() => {}) continue } serverIds.add(pendingInputId) @@ -743,7 +798,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } : {}), ...(serverItem.confirmedPlainText === true ? { confirmedPlainText: true } : {}), - ownerSessionKey: sessionKey, + ownerSessionKey, pendingInputId, pendingClientRequestId: clientRequestId, pendingClientMessageId: clientMessageId, @@ -781,7 +836,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { sortOrdinaryPendingItems() for (const item of [...pendingQueue.value]) { - if (!durableItem(item) || item.ownerSessionKey !== sessionKey) continue + if (!durableItem(item) || queueSessionKey(item.ownerSessionKey) !== ownerSessionKey) continue if (serverIds.has(item.pendingInputId!)) continue const pendingInputId = item.pendingInputId! // The list snapshot may have started before the WAL write or enqueue @@ -794,8 +849,8 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { && !saving && !stagingInFlight ) { - rememberRemoval(sessionKey, pendingInputId) - removePendingIdentity(sessionKey, pendingInputId) + rememberRemoval(ownerSessionKey, pendingInputId) + removePendingIdentity(ownerSessionKey, pendingInputId) continue } if (item.pendingPersistenceState === 'cancelling') { @@ -824,7 +879,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (broadcast) { broadcast.onmessage = event => { const message = event.data as PendingQueueBroadcastMessage | null - const sessionKey = typeof message?.sessionKey === 'string' ? message.sessionKey : '' + const sessionKey = typeof message?.sessionKey === 'string' + ? queueSessionKey(message.sessionKey) + : '' const pendingInputId = typeof message?.pendingInputId === 'string' ? message.pendingInputId : '' @@ -838,7 +895,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { void cancelServerIdentity(sessionKey, pendingInputId).catch(() => {}) return } - if (sessionKey === options.sessionKey.value) { + if (sessionKey === queueSessionKey()) { void hydratePendingQueue(sessionKey) } } @@ -848,7 +905,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { function resolveOwnerRequestId(owner?: PendingQueueOwner): string | undefined { if (owner?.ownerRequestId) return owner.ownerRequestId const context = options.ownerContext?.value - return context?.sessionKey === options.sessionKey.value + return context && queueSessionKey(context.sessionKey) === queueSessionKey() ? context.ownerRequestId : undefined } @@ -878,7 +935,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { attachments: (payload.attachments || []).map(a => ({ ...a })), intent: payload.intent ?? null, ...(payload.confirmedPlainText ? { confirmedPlainText: true } : {}), - ownerSessionKey: options.sessionKey.value, + ownerSessionKey: queueSessionKey(), ...(ownerRequestId ? { ownerRequestId } : {}), } const now = Date.now() @@ -906,7 +963,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } finally { locallyCreatingIds.delete(item.pendingInputId!) } - broadcastChange(item.ownerSessionKey || options.sessionKey.value) + broadcastChange(queueSessionKey(item.ownerSessionKey)) void ensureServerStaged(item) return true })() @@ -975,7 +1032,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { owner?: PendingQueueOwner, ) { const stableRequestId = String(item.clientRequestId || '').trim() - const hiddenControlSessionKey = item.sessionKey || options.sessionKey.value + const hiddenControlSessionKey = queueSessionKey(item.sessionKey) if ( stableRequestId && pendingQueue.value.some(candidate => ( @@ -995,7 +1052,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { text: item.text, attachments: [], intent: null, - ownerSessionKey: options.sessionKey.value, + ownerSessionKey: queueSessionKey(), ...(ownerRequestId ? { ownerRequestId } : {}), hiddenControl: true, displayTextOverride: item.displayText, @@ -1035,7 +1092,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { text: request.message, attachments: [], intent: null, - ownerSessionKey: options.sessionKey.value, + ownerSessionKey: queueSessionKey(), ...(ownerRequestId ? { ownerRequestId } : {}), steerAttempt: { phase: payload.phase || 'submitting', @@ -1053,7 +1110,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { action: 'changed' | 'removed' = 'changed', ): Promise { if (!options.pendingInputWal || !item.pendingInputId) return - const sessionKey = item.ownerSessionKey || options.sessionKey.value + const sessionKey = queueSessionKey(item.ownerSessionKey) await options.pendingInputWal.delete(item.pendingInputId) if (action === 'removed') rememberRemoval(sessionKey, item.pendingInputId) broadcastChange(sessionKey, item.pendingInputId, action) @@ -1093,7 +1150,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ): Promise { if (!durableItem(item)) return true const previousState = item.pendingPersistenceState || 'saving' - const sessionKey = item.ownerSessionKey || options.sessionKey.value + const sessionKey = queueSessionKey(item.ownerSessionKey) const retainAfterCancel = cancelOptions.retainAfterCancel === true if (item.pendingRetainAfterCancel === true && !retainAfterCancel) { try { @@ -1148,7 +1205,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { // before its local WAL is removed. Otherwise a deleted chip can reappear // on the next hydrate. await pendingInputQueue!.cancel({ - key: item.ownerSessionKey || options.sessionKey.value, + key: queueSessionKey(item.ownerSessionKey), pendingInputId: item.pendingInputId!, ...(previousState === 'staged' && item.pendingServerRevision ? { expectedRevision: item.pendingServerRevision } @@ -1182,7 +1239,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { && item.pendingRetainAfterCancel === true ) return true removePendingIdentity( - item.ownerSessionKey || options.sessionKey.value, + queueSessionKey(item.ownerSessionKey), pendingInputId, ) return true @@ -1332,8 +1389,14 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { cancelPendingReorder() clearPendingDrainAfterTerminalTimer() activeQueueLease += 1 - const sourceSessionKey = options.sessionKey.value + const sourceSessionKey = queueSessionKey() + const canonicalTargetSessionKey = queueSessionKey(targetSessionKey) if (sourceSessionKey && pendingQueue.value.length > 0) { + for (const item of pendingQueue.value) { + if (queueSessionKey(item.ownerSessionKey) === sourceSessionKey) { + item.ownerSessionKey = sourceSessionKey + } + } const existing = parkedQueues.get(sourceSessionKey) || [] const existingIds = new Set(existing.map(item => item.pendingInputId).filter(Boolean)) parkedQueues.set(sourceSessionKey, [ @@ -1343,10 +1406,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { )), ]) } - const restored = parkedQueues.get(targetSessionKey) || [] - parkedQueues.delete(targetSessionKey) + const restored = parkedQueues.get(canonicalTargetSessionKey) || [] + parkedQueues.delete(canonicalTargetSessionKey) pendingQueue.value = restored - nextTick(() => void hydratePendingQueue(targetSessionKey)) + nextTick(() => void hydratePendingQueue(canonicalTargetSessionKey)) } function applyAcceptedHandoffCommit( @@ -1354,8 +1417,12 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { targetSessionKey: string, ownerRequestId: string, ) { + targetSessionKey = queueSessionKey(targetSessionKey) const committedById = new Map( - commit.records.map(record => [record.pendingInputId, itemFromWalRecord(record)]), + commit.records.map(record => [record.pendingInputId, itemFromWalRecord({ + ...record, + sessionKey: queueSessionKey(record.sessionKey), + })]), ) const migrated: ChatPendingItem[] = [] const updateOwned = (items: ChatPendingItem[]) => items.flatMap(item => { @@ -1390,6 +1457,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { shouldApply: () => boolean = () => true, handoffSignal?: AbortSignal, ): Promise { + targetSessionKey = queueSessionKey(targetSessionKey) if (!options.pendingInputWal?.acceptHandoff) return false if (options.pendingInputWal.listHandoffs) { const records = await options.pendingInputWal.listHandoffs() @@ -1416,16 +1484,20 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ownerRequestId: string, ): Promise { if (!sourceSessionKey || !targetSessionKey || !ownerRequestId) return + sourceSessionKey = queueSessionKey(sourceSessionKey) + targetSessionKey = queueSessionKey(targetSessionKey) const committed = await acceptDurableHandoff(targetSessionKey, ownerRequestId) if (!committed) return - if (options.sessionKey.value === targetSessionKey) { + if (queueSessionKey() === targetSessionKey) { activeQueueLease += 1 const restored = parkedQueues.get(targetSessionKey) || [] parkedQueues.delete(targetSessionKey) pendingQueue.value = [...pendingQueue.value, ...restored] sortOrdinaryPendingItems() for (const item of pendingQueue.value) { - if (item.ownerSessionKey === targetSessionKey) void ensureServerStaged(item) + if (queueSessionKey(item.ownerSessionKey) === targetSessionKey) { + void ensureServerStaged(item) + } } } broadcastChange(sourceSessionKey) @@ -1439,7 +1511,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ].filter(item => item.ownerRequestId === ownerRequestId && durableItem(item)) await Promise.all(owned.map(item => writeWalItem(item, 'retryable').catch(() => {}))) for (const item of owned) { - broadcastChange(item.ownerSessionKey || options.sessionKey.value, item.pendingInputId) + broadcastChange(queueSessionKey(item.ownerSessionKey), item.pendingInputId) } } @@ -1452,7 +1524,8 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (reorderCommitPromise) await reorderCommitPromise else cancelPendingReorder() if (!shouldCommit()) return - const sourceSessionKey = options.sessionKey.value + const sourceSessionKey = queueSessionKey() + targetSessionKey = queueSessionKey(targetSessionKey) const durableCommitApplied = await acceptDurableHandoff( targetSessionKey, ownerRequestId, @@ -1476,7 +1549,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if ( !durableCommitApplied && ownerRequestId - && item.ownerSessionKey === sourceSessionKey + && queueSessionKey(item.ownerSessionKey) === sourceSessionKey && item.ownerRequestId === ownerRequestId ) { // Keep object identity: an in-flight explicit steer stores its @@ -1505,7 +1578,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { pendingQueue.value = [...targetItems, ...carried] sortOrdinaryPendingItems() for (const item of pendingQueue.value) { - if (item.ownerSessionKey === targetSessionKey) void ensureServerStaged(item) + if (queueSessionKey(item.ownerSessionKey) === targetSessionKey) { + void ensureServerStaged(item) + } } broadcastChange(sourceSessionKey) broadcastChange(targetSessionKey) @@ -1524,19 +1599,26 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { function restoreDurableItemIntoComposer( item: ChatPendingItem, restore: () => void, + lease = { + ownerSessionKey: queueSessionKey(item.ownerSessionKey), + queueLease: activeQueueLease, + composerRevision, + }, ) { - const ownerSessionKey = item.ownerSessionKey || options.sessionKey.value - const queueLease = activeQueueLease void cancelDurableItem(item, { retainAfterCancel: true }).then(retained => { if ( !retained - || options.sessionKey.value !== ownerSessionKey - || activeQueueLease !== queueLease + || disposed + || queueSessionKey(item.ownerSessionKey) !== lease.ownerSessionKey + || queueSessionKey() !== lease.ownerSessionKey + || activeQueueLease !== lease.queueLease + || composerRevision !== lease.composerRevision ) return const index = pendingQueue.value.indexOf(item) if (index < 0) return pendingQueue.value.splice(index, 1) restore() + lease.composerRevision = composerRevision // The composer now owns the retained local-only payload. Remove its WAL // record without reopening a window where a navigation can restore the // source item into a different session's composer. @@ -1656,6 +1738,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { options.pendingSessionIntent.value = options.pendingSessionIntent.value || headIntent || null options.autoResizeTextarea() options.resetInputHistory() + const restoreLease = { + ownerSessionKey: queueSessionKey(), + queueLease: activeQueueLease, + composerRevision, + } for (const item of durable) { restoreDurableItemIntoComposer(item, () => { options.inputText.value = [options.inputText.value, item.text] @@ -1670,7 +1757,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ) options.autoResizeTextarea() options.resetInputHistory() - }) + }, restoreLease) } return true } @@ -1679,8 +1766,8 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { clearPendingDrainAfterTerminalTimer() if (pendingQueue.value.length === 0) return const head = pendingQueue.value[0] - const ownerSessionKey = head?.ownerSessionKey || options.sessionKey.value - if (ownerSessionKey !== options.sessionKey.value) { + const ownerSessionKey = queueSessionKey(head?.ownerSessionKey) + if (ownerSessionKey !== queueSessionKey()) { if (head) head.deliveryState = 'retryable' return } @@ -1692,7 +1779,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { void (async () => { let outcome: PendingDeliveryOutcome = 'retryable_failure' try { - if (options.sessionKey.value === ownerSessionKey) { + if (queueSessionKey() === ownerSessionKey) { outcome = await options.dispatchHiddenControl?.( head, ownerSessionKey, @@ -1730,7 +1817,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { void (async () => { let outcome: PendingDeliveryOutcome = 'retryable_failure' try { - if (options.sessionKey.value === ownerSessionKey) { + if (queueSessionKey() === ownerSessionKey) { outcome = await options.dispatchPendingItem!(item, ownerSessionKey) } } catch { @@ -1749,7 +1836,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { head.deliveryState = 'steering' nextTick(() => { if ( - options.sessionKey.value !== ownerSessionKey + queueSessionKey() !== ownerSessionKey || pendingQueue.value[0] !== head ) return pendingQueue.value.shift() @@ -1889,7 +1976,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (!pendingInputQueue || !supportsServerQueue()) return false const expectedOrder = pendingQueue.value.map(item => item.pendingInputId) try { - const response = { items: await pendingInputQueue.list(options.sessionKey.value) } + const response = { items: await pendingInputQueue.list(queueSessionKey()) } const items = Array.isArray(response.items) ? response.items : [] const serverOrder = items .slice() @@ -1899,7 +1986,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (serverOrder.some((id, index) => id !== expectedOrder[index])) { options.onPendingPersistenceError?.('order_conflict') } - broadcastChange(options.sessionKey.value) + broadcastChange(queueSessionKey()) return true } catch { return false @@ -1952,7 +2039,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (snapshot.mode === 'local') { try { const result = await options.pendingInputWal!.commitOrder!( - options.sessionKey.value, + queueSessionKey(), orderedIds, snapshot.expectedWalRevisions, ) @@ -1962,10 +2049,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { item.pendingPosition = record.position item.pendingWalRevision = record.walRevision } - broadcastChange(options.sessionKey.value) + broadcastChange(queueSessionKey()) } catch { finishPendingReorder() - await hydratePendingQueue(options.sessionKey.value) + await hydratePendingQueue(queueSessionKey()) options.onPendingPersistenceError?.('wal_failed') return } @@ -1974,14 +2061,14 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } try { const response = await pendingInputQueue!.reorder({ - key: options.sessionKey.value, + key: queueSessionKey(), items: pendingQueue.value.map(item => ({ pendingInputId: item.pendingInputId, expectedRevision: item.pendingServerRevision, })), }) await applyServerReorderItems(Array.isArray(response.items) ? response.items : []) - broadcastChange(options.sessionKey.value) + broadcastChange(queueSessionKey()) finishPendingReorder() } catch { // An unknown RPC result may have committed. Keep the delivery barrier @@ -2020,7 +2107,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { function cleanup() { cancelPendingReorder() disposed = true + activeQueueLease += 1 hydrateGeneration += 1 + stopComposerRevisionWatch() clearPendingDrainAfterTerminalTimer() parkedQueues.clear() broadcast?.close() diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts index 7e0fc2637..d34202f6c 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.test.ts @@ -1,8 +1,14 @@ -import { ref } from 'vue' +import { ref, type Ref } from 'vue' import { describe, expect, it, vi } from 'vitest' -import { useChatSessionRuntime, type ChatUsageAccumulator } from './useChatSessionRuntime' +import { useChatPendingQueue } from './useChatPendingQueue' +import { + useChatSessionRuntime, + type ChatUsageAccumulator, + type UseChatSessionRuntimeOptions, +} from './useChatSessionRuntime' import type { Attachment, ChatMessage } from '@/types/chat' +import type { PendingInputWal, PendingInputWalRecord } from '@/utils/chat/pendingInputWal' function emptyUsage(): ChatUsageAccumulator { return { @@ -19,15 +25,20 @@ function emptyUsage(): ChatUsageAccumulator { function runtimeHarness( initialSessionKey: string, generatedSessionKey = 'agent:main:webchat:generated', + queueBindings: { + sessionKey?: Ref + switchPendingQueue?: UseChatSessionRuntimeOptions['switchPendingQueue'] + adoptPendingQueue?: UseChatSessionRuntimeOptions['adoptPendingQueue'] + } = {}, ) { - const sessionKey = ref(initialSessionKey) + const sessionKey = queueBindings.sessionKey || ref(initialSessionKey) const pendingSessionIntent = ref(null) const persistSession = vi.fn((key: string) => { sessionKey.value = key }) const beginSessionResolution = vi.fn() const cancelSessionBootstrap = vi.fn() const setSessionHandoffTarget = vi.fn() - const switchPendingQueue = vi.fn() - const adoptPendingQueue = vi.fn() + const switchPendingQueue = vi.fn(queueBindings.switchPendingQueue || (() => {})) + const adoptPendingQueue = vi.fn(queueBindings.adoptPendingQueue || (() => {})) const retireAttachments = vi.fn() const resetDraftComposer = vi.fn() const bootstrappedSessionKeys: string[] = [] @@ -584,4 +595,72 @@ describe('useChatSessionRuntime Meta draft recovery', () => { 'agent:main:webchat:f', ]) }) + + it('round-trips a legacy alias attachment queue through real canonical runtime switches', async () => { + const legacySession = 'agent:default:webchat:legacy-draft' + const canonicalSession = 'agent:main:webchat:legacy-draft' + const otherSession = 'agent:main:webchat:other' + const sessionKey = ref(legacySession) + const inputText = ref('legacy queued attachment') + const pendingAttachments = ref([{ + kind: 'staged', + local_id: 901, + name: 'legacy-draft.txt', + mime: 'text/plain', + file_uuid: 'legacy-draft-upload', + }]) + const pendingSessionIntent = ref(null) + const records = new Map() + const wal: PendingInputWal = { + put: vi.fn(async record => { + records.set(record.pendingInputId, structuredClone(record)) + }), + list: vi.fn(async ownerSessionKey => [...records.values()] + .filter(record => record.sessionKey === ownerSessionKey) + .map(record => structuredClone(record))), + delete: vi.fn(async pendingInputId => { records.delete(pendingInputId) }), + close: vi.fn(), + } + const pendingQueue = useChatPendingQueue({ + sessionKey, + inputText, + pendingAttachments, + pendingSessionIntent, + isStreaming: ref(false), + isBlocked: () => false, + autoResizeTextarea: vi.fn(), + sendCurrentInput: vi.fn(), + resetInputHistory: vi.fn(), + hasComposer: () => true, + pendingInputWal: wal, + }) + const harness = runtimeHarness(legacySession, 'agent:main:webchat:generated', { + sessionKey, + switchPendingQueue: pendingQueue.switchPendingQueue, + adoptPendingQueue: pendingQueue.adoptPendingQueue, + }) + try { + await expect(pendingQueue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(pendingQueue.pendingQueue.value[0]?.pendingPersistenceState).toBe('local_only') + }) + expect([...records.values()][0]?.sessionKey).toBe(canonicalSession) + + await expect(harness.runtime.switchToSession(otherSession)).resolves.toMatchObject({ + authoritative: true, + }) + expect(pendingQueue.pendingQueue.value).toEqual([]) + + await expect(harness.runtime.switchToSession(canonicalSession)).resolves.toMatchObject({ + authoritative: true, + }) + expect(pendingQueue.pendingQueue.value).toMatchObject([{ + text: 'legacy queued attachment', + ownerSessionKey: canonicalSession, + attachments: [{ name: 'legacy-draft.txt' }], + }]) + } finally { + pendingQueue.cleanup() + } + }) }) From aa1f41039660ed10ab294d32add946519062a14b Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 18:12:34 +0800 Subject: [PATCH 09/18] Close pending recovery race windows --- .../chat/useChatPendingQueue.test.ts | 310 +++++++++++++++++- .../composables/chat/useChatPendingQueue.ts | 145 ++++++-- .../chat/useChatSend.attachments.test.ts | 76 +++++ .../src/composables/chat/useChatSend.ts | 61 +++- .../pendingInputWal.atomicHandoff.test.ts | 107 +++++- .../src/utils/chat/pendingInputWal.ts | 52 ++- 6 files changed, 701 insertions(+), 50 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 586d286d2..ad555b393 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -115,12 +115,35 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { records.set(record.pendingInputId, structuredClone(record)) } }), + retainCancelled: vi.fn(async (record, expectedWalRevision) => { + const current = records.get(record.pendingInputId) + if ( + !current + || current.sessionKey !== record.sessionKey + || current.clientRequestId !== record.clientRequestId + || current.clientMessageId !== record.clientMessageId + || current.state !== 'cancelling' + || current.retainAfterCancel !== true + || (current.walRevision ?? 1) !== expectedWalRevision + ) return null + const retained = { + ...structuredClone(record), + state: 'local_only' as const, + retainAfterCancel: true, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + } + records.set(record.pendingInputId, retained) + return structuredClone(retained) + }), commitOrder: vi.fn(async ( sessionKey: string, orderedIds: string[], expectedWalRevisions: Record, + equivalentSessionKeys: string[] = [], ) => { - const current = [...records.values()].filter(record => record.sessionKey === sessionKey) + const sessionKeys = new Set([sessionKey, ...equivalentSessionKeys]) + const current = [...records.values()].filter(record => sessionKeys.has(record.sessionKey)) if ( current.length !== orderedIds.length || orderedIds.some(id => !current.some(record => record.pendingInputId === id)) @@ -131,6 +154,7 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { if (expectedWalRevisions[pendingInputId] !== revision) throw new Error('conflict') const next = { ...record, + sessionKey, position, walRevision: revision + 1, updatedAt: Date.now(), @@ -1110,6 +1134,72 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('does not rewrite a retained WAL row after a peer removes it', async () => { + vi.stubGlobal( + 'BroadcastChannel', + TestBroadcastChannel as unknown as typeof BroadcastChannel, + ) + const { wal, records } = memoryWal() + const first = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + const second = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + try { + first.inputText.value = 'peer removal wins' + await expect(first.queue.enqueuePendingInput(first.inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(first.queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('local_only') + }) + await second.queue.hydratePendingQueue() + + const pendingId = first.queue.pendingQueue.value[0]!.pendingInputId! + const retainCancelled = wal.retainCancelled! + let releaseRetainedWrite!: () => void + let markRetainedWriteStarted!: () => void + const retainedWriteStarted = new Promise(resolve => { + markRetainedWriteStarted = resolve + }) + const retainedWriteGate = new Promise(resolve => { releaseRetainedWrite = resolve }) + wal.retainCancelled = vi.fn(async (record, expectedWalRevision) => { + if (record.pendingInputId === pendingId) { + markRetainedWriteStarted() + await retainedWriteGate + } + return retainCancelled(record, expectedWalRevision) + }) + + expect(first.queue.editPendingItem(pendingUiId(first.queue, 0))).toBe(true) + await retainedWriteStarted + expect(second.queue.removePendingChip(pendingUiId(second.queue, 0))).toBe(true) + await vi.waitFor(() => { + expect(first.queue.pendingQueue.value).toEqual([]) + expect(second.queue.pendingQueue.value).toEqual([]) + expect(records.size).toBe(0) + }) + + releaseRetainedWrite() + await vi.waitFor(() => expect(records.size).toBe(0)) + expect(first.inputText.value).toBe('') + await first.queue.hydratePendingQueue() + expect(first.queue.pendingQueue.value).toEqual([]) + } finally { + first.queue.cleanup() + second.queue.cleanup() + vi.unstubAllGlobals() + TestBroadcastChannel.channels.clear() + } + }) + it('resolves a queued action by stable UI identity after a peer deletion shifts indexes', async () => { const { inputText, queue } = makeQueue() inputText.value = 'peer removes this first row' @@ -1132,6 +1222,82 @@ describe('useChatPendingQueue delivery state', () => { queue.cleanup() }) + it('restores multiple durable drafts in queue order when cancellation resolves in reverse', async () => { + const { wal } = memoryWal() + const writeWal = wal.put + const cancellationReleases = new Map void>() + let markBothStarted!: () => void + const bothStarted = new Promise(resolve => { markBothStarted = resolve }) + wal.put = vi.fn(async record => { + if (record.state === 'cancelling') { + await new Promise(resolve => { + cancellationReleases.set(record.pendingInputId, resolve) + if (cancellationReleases.size === 2) markBothStarted() + }) + } + await writeWal(record) + }) + const { + inputText, + pendingAttachments, + pendingSessionIntent, + queue, + } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + inputText.value = 'A' + pendingAttachments.value = [{ + kind: 'staged', + local_id: 281, + name: 'A.txt', + mime: 'text/plain', + file_uuid: 'upload-A', + }] + pendingSessionIntent.value = 'intent:A' + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + + inputText.value = 'B' + pendingAttachments.value = [{ + kind: 'staged', + local_id: 282, + name: 'B.txt', + mime: 'text/plain', + file_uuid: 'upload-B', + }] + pendingSessionIntent.value = 'intent:B' + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await vi.waitFor(() => expect(queue.pendingQueue.value.every(item => ( + item.pendingPersistenceState === 'local_only' + ))).toBe(true)) + const [firstId, secondId] = queue.pendingQueue.value.map(item => item.pendingInputId!) + + expect(queue.popAllPendingIntoComposer()).toBe(true) + await bothStarted + cancellationReleases.get(secondId)?.() + await vi.waitFor(() => { + expect(queue.pendingQueue.value.find(item => item.pendingInputId === secondId)) + .toMatchObject({ pendingPersistenceState: 'local_only' }) + }) + expect(inputText.value).toBe('') + expect(pendingAttachments.value).toEqual([]) + + cancellationReleases.get(firstId)?.() + await vi.waitFor(() => { + expect(inputText.value).toBe('A\nB') + expect(queue.pendingQueue.value).toEqual([]) + }) + expect(pendingAttachments.value.map(attachment => attachment.name)).toEqual([ + 'A.txt', + 'B.txt', + ]) + expect(pendingSessionIntent.value).toBe('intent:A') + } finally { + queue.cleanup() + } + }) + it.each([ 'editPendingItem', 'popPendingTail', @@ -1369,6 +1535,74 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('deduplicates reconnect cancellation while a durable draft is returning to the composer', async () => { + const { wal, records } = memoryWal() + let serverRow: Awaited>[number] | null = null + let releaseCancel!: () => void + let markCancelStarted!: () => void + const cancelStarted = new Promise(resolve => { markCancelStarted = resolve }) + const cancelGate = new Promise(resolve => { releaseCancel = resolve }) + const pendingInputQueue: PendingInputQueuePort = { + supportsQueue: () => true, + supportsReorder: () => false, + enqueue: vi.fn(async request => { + serverRow = { + pendingInputId: request.pendingInputId, + clientRequestId: request.clientRequestId || 'request-reconnect', + clientMessageId: request.clientMessageId || 'message-reconnect', + message: request.message, + displayText: request.displayText, + position: 0, + revision: 1, + requestFingerprint: 'fingerprint-reconnect', + } + return { + requestFingerprint: 'fingerprint-reconnect', + revision: 1, + position: 0, + } + }), + list: vi.fn(async () => serverRow ? [serverRow] : []), + cancel: vi.fn(async () => { + markCancelStarted() + await cancelGate + serverRow = null + }), + reorder: vi.fn(async () => ({ items: [] })), + } + const { inputText, queue } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, pendingInputQueue }, + ) + try { + inputText.value = 'restore once after reconnect' + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('staged') + }) + + expect(queue.editPendingItem(pendingUiId(queue, 0))).toBe(true) + await cancelStarted + await queue.hydratePendingQueue() + expect(pendingInputQueue.cancel).toHaveBeenCalledTimes(1) + + releaseCancel() + await vi.waitFor(() => { + expect(inputText.value).toBe('restore once after reconnect') + expect(queue.pendingQueue.value).toEqual([]) + expect(records.size).toBe(0) + }) + await queue.hydratePendingQueue() + expect(queue.pendingQueue.value).toEqual([]) + expect(pendingInputQueue.cancel).toHaveBeenCalledTimes(1) + } finally { + queue.cleanup() + } + }) + it('hydrates and parks a legacy alias WAL row under its canonical queue owner', async () => { const legacySession = 'agent:default:webchat:alias-draft' const canonicalSession = 'agent:main:webchat:alias-draft' @@ -1422,6 +1656,80 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('atomically reorders legacy alias and canonical WAL rows under the canonical owner', async () => { + const legacySession = 'agent:default:webchat:alias-reorder' + const canonicalSession = 'agent:main:webchat:alias-reorder' + const baseRecord = { + schemaVersion: 1 as const, + attachments: [], + intent: null, + state: 'local_only' as const, + mayHaveServerCopy: false, + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const { wal, records } = memoryWal([ + { + ...baseRecord, + pendingInputId: 'pending-legacy-first', + sessionKey: legacySession, + clientRequestId: 'request-legacy-first', + clientMessageId: 'message-legacy-first', + text: 'legacy first', + position: 0, + }, + { + ...baseRecord, + pendingInputId: 'pending-canonical-second', + sessionKey: canonicalSession, + clientRequestId: 'request-canonical-second', + clientMessageId: 'message-canonical-second', + text: 'canonical second', + position: 1, + }, + ]) + const first = makeQueue(undefined, () => false, undefined, undefined, { + sessionKey: ref(canonicalSession), + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + await vi.waitFor(() => { + expect(first.queue.pendingQueue.value.map(item => item.text)).toEqual([ + 'legacy first', + 'canonical second', + ]) + }) + + expect(first.queue.beginPendingReorder(1)).toBe(true) + expect(first.queue.reorderPendingItem(1, 0)).toBe(true) + await first.queue.endPendingReorder() + expect(first.queue.pendingQueue.value.map(item => item.text)).toEqual([ + 'canonical second', + 'legacy first', + ]) + expect([...records.values()].every(record => ( + record.sessionKey === canonicalSession + ))).toBe(true) + first.queue.cleanup() + + const reloaded = makeQueue(undefined, () => false, undefined, undefined, { + sessionKey: ref(canonicalSession), + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await vi.waitFor(() => { + expect(reloaded.queue.pendingQueue.value.map(item => item.text)).toEqual([ + 'canonical second', + 'legacy first', + ]) + }) + } finally { + reloaded.queue.cleanup() + } + }) + it('does not edit a queued annotation batch into plain text', async () => { const { inputText, queue } = makeQueue() await expect(queue.enqueuePendingPayload({ diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 666f8e426..841e8feb0 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -204,6 +204,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { let disposed = false const stagingOperations = new Map>() const cancellationOperations = new Map>() + const cancellationInvalidations = new Map() const locallyCreatingIds = new Set() const removedIdentityOrder: string[] = [] const removedIdentities = new Set() @@ -384,6 +385,13 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { removedIdentities.delete(removedIdentity(sessionKey, pendingInputId)) } + function invalidateCancellation(pendingInputId: string) { + cancellationInvalidations.set( + pendingInputId, + (cancellationInvalidations.get(pendingInputId) ?? 0) + 1, + ) + } + function removePendingIdentity(sessionKey: string, pendingInputId: string) { const ownerSessionKey = queueSessionKey(sessionKey) if (queueSessionKey() === ownerSessionKey) { @@ -776,7 +784,12 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const clientRequestId = serverItem.clientRequestId const clientMessageId = serverItem.clientMessageId if (wasRemoved(ownerSessionKey, pendingInputId)) { - void cancelServerIdentity(ownerSessionKey, pendingInputId).catch(() => {}) + const cancellingItem = pendingQueue.value.find(candidate => ( + candidate.pendingInputId === pendingInputId + && candidate.pendingPersistenceState === 'cancelling' + )) + if (cancellingItem) void retryCancellingItem(cancellingItem) + else void cancelServerIdentity(ownerSessionKey, pendingInputId).catch(() => {}) continue } serverIds.add(pendingInputId) @@ -887,6 +900,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { : '' if (!sessionKey) return if (message?.action === 'removed' && pendingInputId) { + invalidateCancellation(pendingInputId) rememberRemoval(sessionKey, pendingInputId) removePendingIdentity(sessionKey, pendingInputId) void options.pendingInputWal?.delete(pendingInputId).catch(() => {}) @@ -1111,6 +1125,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ): Promise { if (!options.pendingInputWal || !item.pendingInputId) return const sessionKey = queueSessionKey(item.ownerSessionKey) + if (action === 'removed') invalidateCancellation(item.pendingInputId) await options.pendingInputWal.delete(item.pendingInputId) if (action === 'removed') rememberRemoval(sessionKey, item.pendingInputId) broadcastChange(sessionKey, item.pendingInputId, action) @@ -1119,7 +1134,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { async function retainCancelledDraft( item: ChatPendingItem, sessionKey: string, + expectedInvalidation: number, ): Promise { + if ((cancellationInvalidations.get(item.pendingInputId!) ?? 0) !== expectedInvalidation) { + return false + } const previousMayHaveServerCopy = item.pendingMayHaveServerCopy const previousFingerprint = item.pendingRequestFingerprint const previousServerRevision = item.pendingServerRevision @@ -1130,7 +1149,28 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { delete item.pendingServerRevision delete item.pendingPosition try { - await writeWalItem(item, 'local_only') + if (options.pendingInputWal?.retainCancelled) { + const expectedWalRevision = item.pendingWalRevision ?? 1 + const retained = await options.pendingInputWal.retainCancelled( + { + ...walRecordForItem(item, 'local_only'), + walRevision: expectedWalRevision + 1, + }, + expectedWalRevision, + ) + if (!retained) { + removePendingIdentity(sessionKey, item.pendingInputId!) + return false + } + item.pendingPersistenceState = retained.state + item.pendingWalRevision = retained.walRevision + } else { + await writeWalItem(item, 'local_only') + } + if ((cancellationInvalidations.get(item.pendingInputId!) ?? 0) !== expectedInvalidation) { + await options.pendingInputWal?.delete(item.pendingInputId!).catch(() => {}) + return false + } broadcastChange(sessionKey, item.pendingInputId, 'changed') return true } catch { @@ -1144,7 +1184,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } } - async function cancelDurableItem( + async function performDurableCancellation( item: ChatPendingItem, cancelOptions: PendingCancelOptions = {}, ): Promise { @@ -1152,6 +1192,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const previousState = item.pendingPersistenceState || 'saving' const sessionKey = queueSessionKey(item.ownerSessionKey) const retainAfterCancel = cancelOptions.retainAfterCancel === true + const expectedInvalidation = cancellationInvalidations.get(item.pendingInputId!) ?? 0 if (item.pendingRetainAfterCancel === true && !retainAfterCancel) { try { await forgetDurableItem(item, 'removed') @@ -1187,7 +1228,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { return false } try { - if (retainAfterCancel) return retainCancelledDraft(item, sessionKey) + if (retainAfterCancel) { + return retainCancelledDraft(item, sessionKey, expectedInvalidation) + } await forgetDurableItem(item, 'removed') return true } catch { @@ -1211,7 +1254,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ? { expectedRevision: item.pendingServerRevision } : {}), }) - if (retainAfterCancel) return retainCancelledDraft(item, sessionKey) + if (retainAfterCancel) { + return retainCancelledDraft(item, sessionKey, expectedInvalidation) + } await forgetDurableItem(item, 'removed') return true } catch { @@ -1223,14 +1268,29 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } } + function cancelDurableItem( + item: ChatPendingItem, + cancelOptions: PendingCancelOptions = {}, + ): Promise { + const pendingInputId = item.pendingInputId + if (!pendingInputId || !options.pendingInputWal) return Promise.resolve(true) + const existing = cancellationOperations.get(pendingInputId) + if (existing) return existing + const operation = performDurableCancellation(item, cancelOptions).finally(() => { + if (cancellationOperations.get(pendingInputId) === operation) { + cancellationOperations.delete(pendingInputId) + } + }) + cancellationOperations.set(pendingInputId, operation) + return operation + } + function retryCancellingItem(item: ChatPendingItem): Promise { const pendingInputId = item.pendingInputId if (!pendingInputId || item.pendingPersistenceState !== 'cancelling') { return Promise.resolve(false) } - const existing = cancellationOperations.get(pendingInputId) - if (existing) return existing - const operation = cancelDurableItem(item, { + return cancelDurableItem(item, { retainAfterCancel: item.pendingRetainAfterCancel === true, }).then(cancelled => { if (!cancelled) return false @@ -1243,11 +1303,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { pendingInputId, ) return true - }).finally(() => { - cancellationOperations.delete(pendingInputId) }) - cancellationOperations.set(pendingInputId, operation) - return operation } function pendingIndex(pendingUiId: string): number { @@ -1626,6 +1682,40 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { }) } + function restoreDurableItemsIntoComposerInOrder( + items: ChatPendingItem[], + restore: (item: ChatPendingItem) => void, + lease: { + ownerSessionKey: string + queueLease: number + composerRevision: number + }, + ) { + void Promise.all(items.map(item => ( + cancelDurableItem(item, { retainAfterCancel: true }) + ))).then(retainedItems => { + if ( + disposed + || queueSessionKey() !== lease.ownerSessionKey + || activeQueueLease !== lease.queueLease + || composerRevision !== lease.composerRevision + ) return + for (const [index, item] of items.entries()) { + if ( + !retainedItems[index] + || queueSessionKey(item.ownerSessionKey) !== lease.ownerSessionKey + ) continue + const queueIndex = pendingQueue.value.indexOf(item) + if (queueIndex < 0) continue + pendingQueue.value.splice(queueIndex, 1) + restore(item) + lease.composerRevision = composerRevision + // Each retained row is removed only after its ordered composer commit. + void cancelDurableItem(item) + } + }) + } + function editPendingItem(pendingUiId: string): boolean { const index = pendingIndex(pendingUiId) const item = pendingQueue.value[index] @@ -1743,22 +1833,20 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { queueLease: activeQueueLease, composerRevision, } - for (const item of durable) { - restoreDurableItemIntoComposer(item, () => { - options.inputText.value = [options.inputText.value, item.text] - .filter(Boolean) - .join('\n') - options.pendingAttachments.value = [ - ...options.pendingAttachments.value, - ...(item.attachments || []), - ] - options.pendingSessionIntent.value = ( - options.pendingSessionIntent.value || item.intent || null - ) - options.autoResizeTextarea() - options.resetInputHistory() - }, restoreLease) - } + restoreDurableItemsIntoComposerInOrder(durable, item => { + options.inputText.value = [options.inputText.value, item.text] + .filter(Boolean) + .join('\n') + options.pendingAttachments.value = [ + ...options.pendingAttachments.value, + ...(item.attachments || []), + ] + options.pendingSessionIntent.value = ( + options.pendingSessionIntent.value || item.intent || null + ) + options.autoResizeTextarea() + options.resetInputHistory() + }, restoreLease) return true } @@ -2042,6 +2130,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { queueSessionKey(), orderedIds, snapshot.expectedWalRevisions, + walLookupSessionKeys(options.sessionKey.value), ) const byId = new Map(result.records.map(record => [record.pendingInputId, record])) for (const item of pendingQueue.value) { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index a3f83a1fe..12c307c54 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -1278,6 +1278,82 @@ describe('useChatSend attachment payloads', () => { expect(retained).toBeNull() }) + it('restores a delayed failed handoff attachment when a new upload reuses its local id', async () => { + const sessionKey = 'agent:main:webchat:failed-fork-reload' + const recoveredAttachment: Attachment = { + kind: 'staged', + local_id: 1, + name: 'recover-after-reload.txt', + mime: 'text/plain', + file_uuid: 'old-handoff-upload', + } + const newAttachment: Attachment = { + kind: 'staged', + local_id: 1, + name: 'new-after-reload.txt', + mime: 'text/plain', + file_uuid: 'new-upload', + } + const record: ResponseHandoffWalRecord = { + schemaVersion: 1, + ownerRequestId: 'failed-reload-request', + requestSessionKey: sessionKey, + clientRequestId: 'failed-reload-request', + clientMessageId: 'failed-reload-message', + composerText: 'restore after delayed hydration', + recoveryAttachments: [recoveredAttachment], + params: { + sessionKey, + clientRequestId: 'failed-reload-request', + clientMessageId: 'failed-reload-message', + message: 'restore after delayed hydration', + forkBeforeMessageId: 'fork-before-reload', + }, + state: 'failed', + errorCode: 'ATTACHMENT_EXPIRED', + createdAt: 1, + updatedAt: 2, + } + let retained: ResponseHandoffWalRecord | null = record + let releaseList!: () => void + let markListStarted!: () => void + const listStarted = new Promise(resolve => { markListStarted = resolve }) + const listGate = new Promise(resolve => { releaseList = resolve }) + const pendingInputWal: PendingInputWal = { + put: async () => {}, + list: async () => [], + delete: async () => {}, + listHandoffs: async () => { + markListStarted() + await listGate + return retained ? [structuredClone(retained)] : [] + }, + deleteHandoff: async () => { retained = null }, + close: () => {}, + } + const inputText = ref('') + const pendingAttachments = ref([]) + const { api } = makeOptions({ + sessionKey: ref(sessionKey), + inputText, + pendingAttachments, + pendingInputWal, + }) + + const recovery = api.recoverResponseHandoffs() + await listStarted + pendingAttachments.value = [newAttachment] + releaseList() + await recovery + + expect(inputText.value).toBe('restore after delayed hydration') + expect(pendingAttachments.value).toEqual([ + recoveredAttachment, + newAttachment, + ]) + expect(retained).toBeNull() + }) + it('refreshes expired handoff attachments only after a definite rejection', async () => { const parent = 'agent:main:webchat:expired-fork-parent' const child = 'agent:main:webchat:expired-fork-child' diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index ece4a8a82..291f749e0 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -423,6 +423,12 @@ function sameSendableAttachments( }) } +const responseHandoffAttachmentOwners = new WeakMap() + +function responseHandoffAttachmentOwner(ownerRequestId: string, index: number): string { + return `${ownerRequestId}\u0000${index}` +} + function normalizeDocumentContext(value: unknown): TurnDocumentContext | null { if (!value || typeof value !== 'object') return null const raw = value as Record @@ -1702,17 +1708,23 @@ export function useChatSend(options: UseChatSendOptions) { .filter(Boolean) .join('\n') } - const existingAttachmentIds = new Set( - options.pendingAttachments.value.map(attachment => attachment.local_id), - ) - const missingAttachments = record.recoveryAttachments.filter(attachment => ( - !existingAttachmentIds.has(attachment.local_id) - )) + const missingAttachments = record.recoveryAttachments.flatMap((attachment, index) => { + const owner = responseHandoffAttachmentOwner(record.ownerRequestId, index) + return options.pendingAttachments.value.some(candidate => ( + responseHandoffAttachmentOwners.get(candidate) === owner + )) + ? [] + : [{ attachment: { ...attachment }, owner }] + }) if (missingAttachments.length > 0) { options.pendingAttachments.value = [ - ...missingAttachments.map(attachment => ({ ...attachment })), + ...missingAttachments.map(entry => entry.attachment), ...options.pendingAttachments.value, ] + for (const [index, entry] of missingAttachments.entries()) { + const restored = options.pendingAttachments.value[index] + if (restored) responseHandoffAttachmentOwners.set(restored, entry.owner) + } } const forkBeforeMessageId = typeof record.params.forkBeforeMessageId === 'string' ? record.params.forkBeforeMessageId @@ -3558,7 +3570,7 @@ export function useChatSend(options: UseChatSendOptions) { ) { options.inputText.value = [attempt.composerText, currentText].filter(Boolean).join('\n') } - restoreSendableAttachments(attempt.attachments) + restoreSendableAttachments(attempt.attachments, attempt.clientRequestId) if (!options.pendingSessionIntent.value) options.pendingSessionIntent.value = attempt.intent if (!options.pendingForkBeforeMessageId.value) { options.pendingForkBeforeMessageId.value = attempt.forkBeforeMessageId @@ -3571,12 +3583,35 @@ export function useChatSend(options: UseChatSendOptions) { options.autoResizeTextarea() } - function restoreSendableAttachments(attachments: SendableAttachment[]) { + function restoreSendableAttachments( + attachments: SendableAttachment[], + ownerRequestId: string, + ) { if (attachments.length === 0) return - const currentLocalIds = new Set(options.pendingAttachments.value.map(attachment => attachment.local_id)) - const missing = attachments.filter(attachment => !currentLocalIds.has(attachment.local_id)) - if (missing.length > 0) { - options.pendingAttachments.value = [...missing, ...options.pendingAttachments.value] + const additions: Array<{ attachment: SendableAttachment, owner: string }> = [] + for (const [index, attachment] of attachments.entries()) { + const owner = responseHandoffAttachmentOwner(ownerRequestId, index) + const current = options.pendingAttachments.value.find(candidate => ( + isSendableAttachment(candidate) + && candidate.local_id === attachment.local_id + && JSON.stringify(serializeSendableAttachment(candidate)) + === JSON.stringify(serializeSendableAttachment(attachment)) + )) + if (current) { + responseHandoffAttachmentOwners.set(current, owner) + } else { + additions.push({ attachment, owner }) + } + } + if (additions.length > 0) { + options.pendingAttachments.value = [ + ...additions.map(entry => entry.attachment), + ...options.pendingAttachments.value, + ] + for (const [index, entry] of additions.entries()) { + const restored = options.pendingAttachments.value[index] + if (restored) responseHandoffAttachmentOwners.set(restored, entry.owner) + } } } diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index 914346302..48a1d7ea6 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -269,7 +269,112 @@ class ControlledObjectStore { } } -describe('BrowserPendingInputWal atomic handoff cancellation', () => { +describe('BrowserPendingInputWal atomic mutations', () => { + it('does not retain a cancelled draft after another owner deletes its WAL row', async () => { + const factory = new ControlledIdbFactory() + const wal = createPendingInputWal(factory.idbFactory) + expect(wal).not.toBeNull() + const cancelling: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId: 'pending-retain-cas', + sessionKey: 'agent:main:webchat:retain-cas', + clientRequestId: 'request-retain-cas', + clientMessageId: 'message-retain-cas', + text: 'retain only while this tombstone owns the row', + attachments: [], + intent: null, + state: 'cancelling', + mayHaveServerCopy: false, + retainAfterCancel: true, + walRevision: 2, + createdAt: 1, + updatedAt: 2, + } + const retained = { + ...cancelling, + state: 'local_only' as const, + walRevision: 3, + } + await wal!.put(cancelling) + await wal!.delete(cancelling.pendingInputId) + + await expect(wal!.retainCancelled!(retained, 2)).resolves.toBeNull() + expect(factory.record(PENDING_STORE, cancelling.pendingInputId)).toBeUndefined() + + await wal!.put(cancelling) + await expect(wal!.retainCancelled!(retained, 2)).resolves.toMatchObject({ + state: 'local_only', + retainAfterCancel: true, + walRevision: 3, + }) + expect(factory.record(PENDING_STORE, cancelling.pendingInputId)).toMatchObject({ + state: 'local_only', + walRevision: 3, + }) + wal!.close() + }) + + it('commits equivalent legacy session aliases under the canonical reorder owner', async () => { + const factory = new ControlledIdbFactory() + const wal = createPendingInputWal(factory.idbFactory) + expect(wal).not.toBeNull() + const canonicalSession = 'agent:main:webchat:alias-order' + const legacySession = 'agent:default:webchat:alias-order' + const record = ( + pendingInputId: string, + sessionKey: string, + text: string, + position: number, + ): PendingInputWalRecord => ({ + schemaVersion: 1, + pendingInputId, + sessionKey, + clientRequestId: `request-${pendingInputId}`, + clientMessageId: `message-${pendingInputId}`, + text, + attachments: [], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + position, + walRevision: 1, + createdAt: position + 1, + updatedAt: position + 1, + }) + const legacy = record('legacy-row', legacySession, 'legacy', 0) + const canonical = record('canonical-row', canonicalSession, 'canonical', 1) + await wal!.put(legacy) + await wal!.put(canonical) + + const result = await wal!.commitOrder!( + canonicalSession, + ['canonical-row', 'legacy-row'], + { 'legacy-row': 1, 'canonical-row': 1 }, + [canonicalSession, legacySession], + ) + + expect(result.records).toMatchObject([ + { + pendingInputId: 'canonical-row', + sessionKey: canonicalSession, + position: 0, + walRevision: 2, + }, + { + pendingInputId: 'legacy-row', + sessionKey: canonicalSession, + position: 1, + walRevision: 2, + }, + ]) + expect(factory.record(PENDING_STORE, 'legacy-row')).toMatchObject({ + sessionKey: canonicalSession, + position: 1, + walRevision: 2, + }) + wal!.close() + }) + it('rolls back both stores when the handoff epoch aborts after both writes are queued', async () => { const factory = new ControlledIdbFactory() const wal = createPendingInputWal(factory.idbFactory) diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index 0e565aa98..9c7cedec2 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -84,10 +84,16 @@ export interface PendingInputWal { list: (sessionKey: string) => Promise delete: (pendingInputId: string) => Promise putMany?: (records: PendingInputWalRecord[]) => Promise + /** Convert one cancelling tombstone into a retained local draft only while its exact WAL revision still owns the row. */ + retainCancelled?: ( + record: PendingInputWalRecord, + expectedWalRevision: number, + ) => Promise commitOrder?: ( sessionKey: string, orderedIds: string[], expectedWalRevisions: Record, + equivalentSessionKeys?: string[], ) => Promise putHandoff?: (record: ResponseHandoffWalRecord) => Promise /** Atomically create a handoff without replacing another dispatcher's record. */ @@ -323,6 +329,38 @@ class BrowserPendingInputWal implements PendingInputWal { await transactionDone(transaction) } + async retainCancelled( + record: PendingInputWalRecord, + expectedWalRevision: number, + ): Promise { + const database = await this.database() + const transaction = database.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const raw = await requestResult(store.get(record.pendingInputId)) + if ( + !isPendingInputWalRecord(raw) + || raw.sessionKey !== record.sessionKey + || raw.clientRequestId !== record.clientRequestId + || raw.clientMessageId !== record.clientMessageId + || raw.state !== 'cancelling' + || raw.retainAfterCancel !== true + || (raw.walRevision ?? 1) !== expectedWalRevision + ) { + await transactionDone(transaction) + return null + } + const retained = cloneRecord({ + ...record, + state: 'local_only', + retainAfterCancel: true, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + }) + store.put(retained) + await transactionDone(transaction) + return retained + } + async list(sessionKey: string): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readonly') @@ -348,17 +386,16 @@ class BrowserPendingInputWal implements PendingInputWal { sessionKey: string, orderedIds: string[], expectedWalRevisions: Record, + equivalentSessionKeys: string[] = [], ): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readwrite') const store = transaction.objectStore(STORE_NAME) - const index = store.index('session_created') - const range = IDBKeyRange.bound( - [sessionKey, Number.MIN_SAFE_INTEGER], - [sessionKey, Number.MAX_SAFE_INTEGER], - ) - const raw = await requestResult(index.getAll(range)) - const records = (raw as unknown[]).filter(isPendingInputWalRecord) + const raw = await requestResult(store.getAll()) + const sessionKeys = new Set([sessionKey, ...equivalentSessionKeys]) + const records = (raw as unknown[]) + .filter(isPendingInputWalRecord) + .filter(record => sessionKeys.has(record.sessionKey)) const byId = new Map(records.map(record => [record.pendingInputId, record])) if ( orderedIds.length !== records.length @@ -377,6 +414,7 @@ class BrowserPendingInputWal implements PendingInputWal { } const next = cloneRecord({ ...record, + sessionKey, position, walRevision: currentRevision + 1, updatedAt: Date.now(), From f412fc4d34b412e6f4537d3ead2b49d3577d2f7f Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 19:59:17 +0800 Subject: [PATCH 10/18] Preserve destructive queue ordering --- .../components/chat/ChatComposer.i18n.test.ts | 42 ++++++++- .../chat/useChatPendingQueue.test.ts | 91 +++++++++++++++++++ .../composables/chat/useChatPendingQueue.ts | 45 +++++++-- .../chat/useChatSend.attachments.test.ts | 8 +- .../src/composables/chat/useChatSend.ts | 15 ++- 5 files changed, 185 insertions(+), 16 deletions(-) diff --git a/opensquilla-webui/src/components/chat/ChatComposer.i18n.test.ts b/opensquilla-webui/src/components/chat/ChatComposer.i18n.test.ts index a1efe5380..f03f8f275 100644 --- a/opensquilla-webui/src/components/chat/ChatComposer.i18n.test.ts +++ b/opensquilla-webui/src/components/chat/ChatComposer.i18n.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it } from 'vitest' -import { createApp, nextTick } from 'vue' +import { createApp, h, nextTick, ref } from 'vue' import i18n, { loadLocaleMessages } from '@/i18n' import type { Attachment } from '@/types/chat' @@ -35,6 +35,46 @@ afterEach(() => { }) describe('ChatComposer attachment localization', () => { + it('renders and removes the intended attachment when recovered and new IDs differ', async () => { + const attachments = ref([ + { + kind: 'staged', + local_id: -1, + name: 'recovered.txt', + mime: 'text/plain', + file_uuid: 'recovered-upload', + }, + { + kind: 'staged', + local_id: 1, + name: 'new.txt', + mime: 'text/plain', + file_uuid: 'new-upload', + }, + ]) + const el = document.createElement('div') + document.body.appendChild(el) + const app = createApp({ + render: () => h(ChatComposer, { + ...BASE_PROPS, + attachments: attachments.value, + onRemoveAttachment: (index: number) => attachments.value.splice(index, 1), + } as any), + }) + app.use(i18n) + app.mount(el) + await nextTick() + + expect([...el.querySelectorAll('.attachment-chip__name')].map(node => node.textContent)) + .toEqual(['recovered.txt', 'new.txt']) + el.querySelectorAll('.attachment-remove')[0]?.click() + await nextTick() + expect([...el.querySelectorAll('.attachment-chip__name')].map(node => node.textContent)) + .toEqual(['new.txt']) + + app.unmount() + }) + it('localizes failed status, fallback file label, and retry accessibility text', async () => { await loadLocaleMessages('zh-Hans') i18n.global.locale.value = 'zh-Hans' diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index ad555b393..d0fe8d404 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -1298,6 +1298,47 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('keeps later retained drafts queued behind an earlier cancellation failure', async () => { + const { wal } = memoryWal() + const { inputText, queue } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + try { + for (const text of ['A', 'B']) { + inputText.value = text + await expect(queue.enqueuePendingInput(text)).resolves.toBe(true) + } + await vi.waitFor(() => expect(queue.pendingQueue.value.every(item => ( + item.pendingPersistenceState === 'local_only' + ))).toBe(true)) + const firstId = queue.pendingQueue.value[0]!.pendingInputId! + const writeWal = wal.put + wal.put = vi.fn(async record => { + if (record.pendingInputId === firstId && record.state === 'cancelling') { + throw new Error('lost cancellation acknowledgement') + } + await writeWal(record) + }) + + expect(queue.popAllPendingIntoComposer()).toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value[1]).toMatchObject({ + text: 'B', + pendingPersistenceState: 'local_only', + pendingRetainAfterCancel: true, + }) + }) + expect(inputText.value).toBe('') + expect(queue.pendingQueue.value.map(item => item.text)).toEqual(['A', 'B']) + } finally { + queue.cleanup() + } + }) + it.each([ 'editPendingItem', 'popPendingTail', @@ -1471,6 +1512,56 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('chains a destructive clear after an in-flight retained cancellation', async () => { + const { wal, records } = memoryWal() + const retainCancelled = wal.retainCancelled! + let releaseRetain!: () => void + let markRetainStarted!: () => void + const retainStarted = new Promise(resolve => { markRetainStarted = resolve }) + const retainGate = new Promise(resolve => { releaseRetain = resolve }) + wal.retainCancelled = vi.fn(async (record, expectedWalRevision) => { + markRetainStarted() + await retainGate + return retainCancelled(record, expectedWalRevision) + }) + const initial = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + initial.inputText.value = 'clear this retained draft' + await expect(initial.queue.enqueuePendingInput(initial.inputText.value)).resolves.toBe(true) + await vi.waitFor(() => { + expect(initial.queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('local_only') + }) + + expect(initial.queue.editPendingItem(pendingUiId(initial.queue, 0))).toBe(true) + await retainStarted + initial.inputText.value = 'new composer text' + initial.queue.clearPendingQueue() + releaseRetain() + + await vi.waitFor(() => { + expect(initial.queue.pendingQueue.value).toEqual([]) + expect(records.size).toBe(0) + }) + expect(initial.inputText.value).toBe('new composer text') + + const reloaded = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await reloaded.queue.hydratePendingQueue() + expect(reloaded.queue.pendingQueue.value).toEqual([]) + } finally { + reloaded.queue.cleanup() + } + } finally { + initial.queue.cleanup() + } + }) + it('retains delayed composer recovery in the WAL after cleanup for the next hydrate', async () => { const { wal, records } = memoryWal() const writeWal = wal.put diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 841e8feb0..e29eafe90 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -203,7 +203,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { let hydrateGeneration = 0 let disposed = false const stagingOperations = new Map>() - const cancellationOperations = new Map>() + const cancellationOperations = new Map + retainAfterCancel: boolean + }>() const cancellationInvalidations = new Map() const locallyCreatingIds = new Set() const removedIdentityOrder: string[] = [] @@ -1193,7 +1196,12 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const sessionKey = queueSessionKey(item.ownerSessionKey) const retainAfterCancel = cancelOptions.retainAfterCancel === true const expectedInvalidation = cancellationInvalidations.get(item.pendingInputId!) ?? 0 - if (item.pendingRetainAfterCancel === true && !retainAfterCancel) { + if ( + item.pendingRetainAfterCancel === true + && !retainAfterCancel + && item.pendingPersistenceState === 'local_only' + && item.pendingMayHaveServerCopy === false + ) { try { await forgetDurableItem(item, 'removed') return true @@ -1274,14 +1282,29 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ): Promise { const pendingInputId = item.pendingInputId if (!pendingInputId || !options.pendingInputWal) return Promise.resolve(true) + const retainAfterCancel = cancelOptions.retainAfterCancel === true const existing = cancellationOperations.get(pendingInputId) - if (existing) return existing + if (existing) { + if (!existing.retainAfterCancel || retainAfterCancel) return existing.promise + const chained = existing.promise + .then(() => performDurableCancellation(item)) + .finally(() => { + if (cancellationOperations.get(pendingInputId)?.promise === chained) { + cancellationOperations.delete(pendingInputId) + } + }) + cancellationOperations.set(pendingInputId, { + promise: chained, + retainAfterCancel: false, + }) + return chained + } const operation = performDurableCancellation(item, cancelOptions).finally(() => { - if (cancellationOperations.get(pendingInputId) === operation) { + if (cancellationOperations.get(pendingInputId)?.promise === operation) { cancellationOperations.delete(pendingInputId) } }) - cancellationOperations.set(pendingInputId, operation) + cancellationOperations.set(pendingInputId, { promise: operation, retainAfterCancel }) return operation } @@ -1399,6 +1422,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { function clearPendingQueue() { cancelPendingReorder() clearPendingDrainAfterTerminalTimer() + activeQueueLease += 1 for (const item of [...pendingQueue.value]) { if ( item.deliveryState === 'steering' @@ -1701,11 +1725,14 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { || composerRevision !== lease.composerRevision ) return for (const [index, item] of items.entries()) { - if ( - !retainedItems[index] - || queueSessionKey(item.ownerSessionKey) !== lease.ownerSessionKey - ) continue const queueIndex = pendingQueue.value.indexOf(item) + if (!retainedItems[index]) { + // A failed predecessor that still owns a queue slot is an ordering + // barrier. A peer-terminal row is already absent and can be skipped. + if (queueIndex >= 0) break + continue + } + if (queueSessionKey(item.ownerSessionKey) !== lease.ownerSessionKey) break if (queueIndex < 0) continue pendingQueue.value.splice(queueIndex, 1) restore(item) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 12c307c54..4c5e8e50e 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -1273,7 +1273,10 @@ describe('useChatSend attachment payloads', () => { expect(rpc.call).not.toHaveBeenCalled() expect(inputText.value).toBe('restore the fork draft') - expect(pendingAttachments.value).toEqual([attachment]) + expect(pendingAttachments.value).toEqual([{ + ...attachment, + local_id: -1, + }]) expect(pendingForkBeforeMessageId.value).toBe('fork-source-message') expect(retained).toBeNull() }) @@ -1348,9 +1351,10 @@ describe('useChatSend attachment payloads', () => { expect(inputText.value).toBe('restore after delayed hydration') expect(pendingAttachments.value).toEqual([ - recoveredAttachment, + { ...recoveredAttachment, local_id: -1 }, newAttachment, ]) + expect(new Set(pendingAttachments.value.map(attachment => attachment.local_id)).size).toBe(2) expect(retained).toBeNull() }) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 291f749e0..b7c588194 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -1708,13 +1708,20 @@ export function useChatSend(options: UseChatSendOptions) { .filter(Boolean) .join('\n') } + const usedLocalIds = new Set( + options.pendingAttachments.value.map(attachment => attachment.local_id), + ) + let nextRecoveredLocalId = -1 const missingAttachments = record.recoveryAttachments.flatMap((attachment, index) => { const owner = responseHandoffAttachmentOwner(record.ownerRequestId, index) - return options.pendingAttachments.value.some(candidate => ( + if (options.pendingAttachments.value.some(candidate => ( responseHandoffAttachmentOwners.get(candidate) === owner - )) - ? [] - : [{ attachment: { ...attachment }, owner }] + ))) return [] + while (usedLocalIds.has(nextRecoveredLocalId)) nextRecoveredLocalId -= 1 + const localId = nextRecoveredLocalId + usedLocalIds.add(localId) + nextRecoveredLocalId -= 1 + return [{ attachment: { ...attachment, local_id: localId }, owner }] }) if (missingAttachments.length > 0) { options.pendingAttachments.value = [ From 1a9d4ced8c8d05d60f29ed3b0fba62463f8a9f08 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 20:35:19 +0800 Subject: [PATCH 11/18] Preserve live queue CAS conflicts --- .../chat/useChatPendingQueue.test.ts | 89 ++++++++++++++++++- .../composables/chat/useChatPendingQueue.ts | 22 ++++- .../pendingInputWal.atomicHandoff.test.ts | 21 ++++- .../src/utils/chat/pendingInputWal.ts | 17 +++- 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index d0fe8d404..2d02a33eb 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -125,7 +125,10 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { || current.state !== 'cancelling' || current.retainAfterCancel !== true || (current.walRevision ?? 1) !== expectedWalRevision - ) return null + ) return { + applied: false, + record: current ? structuredClone(current) : null, + } const retained = { ...structuredClone(record), state: 'local_only' as const, @@ -134,7 +137,7 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { updatedAt: Date.now(), } records.set(record.pendingInputId, retained) - return structuredClone(retained) + return { applied: true, record: structuredClone(retained) } }), commitOrder: vi.fn(async ( sessionKey: string, @@ -1339,6 +1342,88 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('keeps adjacent drafts ordered when different tabs win their retain CAS', async () => { + vi.stubGlobal( + 'BroadcastChannel', + TestBroadcastChannel as unknown as typeof BroadcastChannel, + ) + const { wal, records } = memoryWal() + const sharedRetain = wal.retainCancelled! + let retainCalls = 0 + let releaseRetainCalls!: () => void + const allRetainCalls = new Promise(resolve => { releaseRetainCalls = resolve }) + const waitForAllRetainCalls = async () => { + retainCalls += 1 + if (retainCalls === 4) releaseRetainCalls() + await allRetainCalls + } + let markFirstWonA!: () => void + let markSecondWonB!: () => void + const firstWonA = new Promise(resolve => { markFirstWonA = resolve }) + const secondWonB = new Promise(resolve => { markSecondWonB = resolve }) + let firstId = '' + let secondId = '' + const firstWal: PendingInputWal = { + ...wal, + retainCancelled: vi.fn(async (record, expectedWalRevision) => { + await waitForAllRetainCalls() + if (record.pendingInputId === firstId) { + const result = await sharedRetain(record, expectedWalRevision) + markFirstWonA() + return result + } + await secondWonB + return sharedRetain(record, expectedWalRevision) + }), + } + const secondWal: PendingInputWal = { + ...wal, + retainCancelled: vi.fn(async (record, expectedWalRevision) => { + await waitForAllRetainCalls() + if (record.pendingInputId === secondId) { + const result = await sharedRetain(record, expectedWalRevision) + markSecondWonB() + return result + } + await firstWonA + return sharedRetain(record, expectedWalRevision) + }), + } + const first = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: firstWal, + hasRpcMethod: () => false, + }) + const second = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: secondWal, + hasRpcMethod: () => false, + }) + try { + for (const text of ['A', 'B']) { + first.inputText.value = text + await expect(first.queue.enqueuePendingInput(text)).resolves.toBe(true) + } + await second.queue.hydratePendingQueue() + firstId = first.queue.pendingQueue.value[0]!.pendingInputId! + secondId = first.queue.pendingQueue.value[1]!.pendingInputId! + + expect(first.queue.popAllPendingIntoComposer()).toBe(true) + expect(second.queue.popAllPendingIntoComposer()).toBe(true) + + await vi.waitFor(() => { + expect(first.inputText.value).toBe('A') + expect(second.inputText.value).toBe('') + expect(first.queue.pendingQueue.value.map(item => item.text)).toEqual(['B']) + expect(second.queue.pendingQueue.value.map(item => item.text)).toEqual(['B']) + expect([...records.values()].map(record => record.text)).toEqual(['B']) + }) + } finally { + first.queue.cleanup() + second.queue.cleanup() + vi.unstubAllGlobals() + TestBroadcastChannel.channels.clear() + } + }) + it.each([ 'editPendingItem', 'popPendingTail', diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index e29eafe90..e56fe6588 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -1154,17 +1154,35 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { try { if (options.pendingInputWal?.retainCancelled) { const expectedWalRevision = item.pendingWalRevision ?? 1 - const retained = await options.pendingInputWal.retainCancelled( + const mutation = await options.pendingInputWal.retainCancelled( { ...walRecordForItem(item, 'local_only'), walRevision: expectedWalRevision + 1, }, expectedWalRevision, ) - if (!retained) { + if (!mutation.applied) { + if (mutation.record) { + // Another tab still owns a live row. Keep this queue slot as an + // ordering barrier and allow a later hydrate to reconcile it. + forgetRemoval(sessionKey, item.pendingInputId!) + if ( + mutation.record.sessionKey === sessionKey + && mutation.record.clientRequestId === item.pendingClientRequestId + && mutation.record.clientMessageId === item.pendingClientMessageId + ) { + Object.assign(item, itemFromWalRecord(mutation.record)) + delete item.deliveryState + delete item.pendingRequestFingerprint + delete item.pendingServerRevision + delete item.pendingPosition + } + return false + } removePendingIdentity(sessionKey, item.pendingInputId!) return false } + const retained = mutation.record! item.pendingPersistenceState = retained.state item.pendingWalRevision = retained.walRevision } else { diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index 48a1d7ea6..fb9cc9ba3 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -298,19 +298,32 @@ describe('BrowserPendingInputWal atomic mutations', () => { await wal!.put(cancelling) await wal!.delete(cancelling.pendingInputId) - await expect(wal!.retainCancelled!(retained, 2)).resolves.toBeNull() + await expect(wal!.retainCancelled!(retained, 2)).resolves.toEqual({ + applied: false, + record: null, + }) expect(factory.record(PENDING_STORE, cancelling.pendingInputId)).toBeUndefined() await wal!.put(cancelling) await expect(wal!.retainCancelled!(retained, 2)).resolves.toMatchObject({ - state: 'local_only', - retainAfterCancel: true, - walRevision: 3, + applied: true, + record: { + state: 'local_only', + retainAfterCancel: true, + walRevision: 3, + }, }) expect(factory.record(PENDING_STORE, cancelling.pendingInputId)).toMatchObject({ state: 'local_only', walRevision: 3, }) + await expect(wal!.retainCancelled!(retained, 2)).resolves.toMatchObject({ + applied: false, + record: { + state: 'local_only', + walRevision: 3, + }, + }) wal!.close() }) diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index 9c7cedec2..c6cb4bca5 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -69,6 +69,12 @@ export interface PendingInputOrderCommit { records: PendingInputWalRecord[] } +export interface PendingInputRetainMutation { + applied: boolean + /** The current live row, or null only when no valid row owns this identity. */ + record: PendingInputWalRecord | null +} + export interface AcceptedHandoffCommit { handoff: ResponseHandoffWalRecord records: PendingInputWalRecord[] @@ -88,7 +94,7 @@ export interface PendingInputWal { retainCancelled?: ( record: PendingInputWalRecord, expectedWalRevision: number, - ) => Promise + ) => Promise commitOrder?: ( sessionKey: string, orderedIds: string[], @@ -332,7 +338,7 @@ class BrowserPendingInputWal implements PendingInputWal { async retainCancelled( record: PendingInputWalRecord, expectedWalRevision: number, - ): Promise { + ): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readwrite') const store = transaction.objectStore(STORE_NAME) @@ -347,7 +353,10 @@ class BrowserPendingInputWal implements PendingInputWal { || (raw.walRevision ?? 1) !== expectedWalRevision ) { await transactionDone(transaction) - return null + return { + applied: false, + record: isPendingInputWalRecord(raw) ? cloneRecord(raw) : null, + } } const retained = cloneRecord({ ...record, @@ -358,7 +367,7 @@ class BrowserPendingInputWal implements PendingInputWal { }) store.put(retained) await transactionDone(transaction) - return retained + return { applied: true, record: retained } } async list(sessionKey: string): Promise { From 1a1ea713b2096cd1b0bae3e0f7e0bc310f1a1922 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 20:49:12 +0800 Subject: [PATCH 12/18] Preserve mixed queue recovery order --- .../chat/useChatPendingQueue.test.ts | 259 ++++++++++++++++++ .../composables/chat/useChatPendingQueue.ts | 90 ++++-- 2 files changed, 325 insertions(+), 24 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 2d02a33eb..0f01a1df7 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -1203,6 +1203,82 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('does not turn a peer-owned destructive tombstone back into a retained draft', async () => { + const { wal, records } = memoryWal() + const sharedRetain = wal.retainCancelled! + let markDestructiveWrite!: () => void + const destructiveWrite = new Promise(resolve => { markDestructiveWrite = resolve }) + const retainingWal: PendingInputWal = { + ...wal, + retainCancelled: vi.fn(async (record, expectedWalRevision) => { + await destructiveWrite + return sharedRetain(record, expectedWalRevision) + }), + } + const retaining = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: retainingWal, + hasRpcMethod: () => false, + }) + const destructive = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + retaining.inputText.value = 'destructive peer owns this tombstone' + await expect(retaining.queue.enqueuePendingInput(retaining.inputText.value)) + .resolves.toBe(true) + await destructive.queue.hydratePendingQueue() + const pendingInputId = retaining.queue.pendingQueue.value[0]!.pendingInputId! + const writeWal = wal.put + wal.put = vi.fn(async record => { + await writeWal(record) + if ( + record.pendingInputId === pendingInputId + && record.state === 'cancelling' + && record.retainAfterCancel !== true + ) markDestructiveWrite() + }) + wal.delete = vi.fn(() => new Promise(() => { + // Model a destructive owner closing after its tombstone commit but + // before the final IndexedDB delete settles. + })) + + expect(retaining.queue.editPendingItem(pendingUiId(retaining.queue, 0))).toBe(true) + expect(destructive.queue.removePendingChip(pendingUiId(destructive.queue, 0))).toBe(true) + + await vi.waitFor(() => { + expect(records.get(pendingInputId)?.state).toBe('cancelling') + expect(records.get(pendingInputId)?.retainAfterCancel).toBeUndefined() + expect(retaining.queue.pendingQueue.value[0]?.pendingPersistenceState) + .toBe('cancelling') + expect(retaining.queue.pendingQueue.value[0]?.pendingRetainAfterCancel) + .toBeUndefined() + expect(destructive.queue.pendingQueue.value[0]?.pendingPersistenceState) + .toBe('cancelling') + expect(destructive.queue.pendingQueue.value[0]?.pendingRetainAfterCancel) + .toBeUndefined() + }) + expect(retaining.inputText.value).toBe('') + + const reloaded = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await reloaded.queue.hydratePendingQueue() + expect(reloaded.queue.pendingQueue.value[0]?.pendingPersistenceState) + .toBe('cancelling') + expect(reloaded.queue.pendingQueue.value[0]?.pendingRetainAfterCancel) + .toBeUndefined() + } finally { + reloaded.queue.cleanup() + } + } finally { + retaining.queue.cleanup() + destructive.queue.cleanup() + } + }) + it('resolves a queued action by stable UI identity after a peer deletion shifts indexes', async () => { const { inputText, queue } = makeQueue() inputText.value = 'peer removes this first row' @@ -1301,6 +1377,61 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('re-keys colliding attachment IDs from independently persisted queue rows', async () => { + const record = ( + pendingInputId: string, + text: string, + position: number, + ): PendingInputWalRecord => ({ + schemaVersion: 1, + pendingInputId, + sessionKey: 'agent:main:webchat:test', + clientRequestId: `request-${text}`, + clientMessageId: `message-${text}`, + text, + attachments: [{ + kind: 'staged', + local_id: 1, + name: `${text}.txt`, + mime: 'text/plain', + file_uuid: `upload-${text}`, + }], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + position, + walRevision: 1, + createdAt: position + 1, + updatedAt: position + 1, + }) + const { wal } = memoryWal([ + record('pending-tab-A', 'A', 0), + record('pending-tab-B', 'B', 1), + ]) + const { inputText, pendingAttachments, queue } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + try { + await queue.hydratePendingQueue() + expect(queue.popAllPendingIntoComposer()).toBe(true) + await vi.waitFor(() => expect(queue.pendingQueue.value).toEqual([])) + + expect(inputText.value).toBe('A\nB') + expect(pendingAttachments.value.map(attachment => attachment.name)) + .toEqual(['A.txt', 'B.txt']) + expect(pendingAttachments.value.map(attachment => attachment.local_id)) + .toEqual([1, -1]) + expect(new Set(pendingAttachments.value.map(attachment => attachment.local_id)).size) + .toBe(2) + } finally { + queue.cleanup() + } + }) + it('keeps later retained drafts queued behind an earlier cancellation failure', async () => { const { wal } = memoryWal() const { inputText, queue } = makeQueue( @@ -1342,6 +1473,134 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('preserves mixed queue order when delayed cancellation loses the composer lease', async () => { + const { wal } = memoryWal() + const writeWal = wal.put + const cancellationReleases = new Map void>() + let markBothStarted!: () => void + const bothStarted = new Promise(resolve => { markBothStarted = resolve }) + wal.put = vi.fn(async record => { + if (record.state === 'cancelling') { + await new Promise(resolve => { + cancellationReleases.set(record.pendingInputId, resolve) + if (cancellationReleases.size === 2) markBothStarted() + }) + } + await writeWal(record) + }) + const { inputText, queue, sessionKey } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + try { + for (const text of ['A', 'C']) { + inputText.value = text + await expect(queue.enqueuePendingInput(text)).resolves.toBe(true) + } + await vi.waitFor(() => expect(queue.pendingQueue.value.every(item => ( + item.pendingPersistenceState === 'local_only' + ))).toBe(true)) + queue.pendingQueue.value.splice(1, 0, { + pendingUiId: 'pending-server-B', + pendingInputId: 'pending-server-B', + pendingClientRequestId: 'request-server-B', + pendingClientMessageId: 'message-server-B', + pendingPersistenceState: 'staged', + pendingMayHaveServerCopy: true, + pendingWalRevision: 1, + pendingCreatedAt: 2, + ownerSessionKey: sessionKey.value, + text: 'B', + attachments: [{ + kind: 'staged', + local_id: 283, + name: 'server-B.txt', + mime: 'text/plain', + durable_material: true, + }], + intent: null, + }) + + expect(queue.popAllPendingIntoComposer()).toBe(true) + await bothStarted + expect(queue.pendingQueue.value.map(item => item.text)).toEqual(['A', 'B', 'C']) + + inputText.value = 'new composer text' + await nextTick() + for (const release of cancellationReleases.values()) release() + await vi.waitFor(() => expect(queue.pendingQueue.value.every(item => ( + item.pendingPersistenceState === 'local_only' + || item.pendingPersistenceState === 'staged' + ))).toBe(true)) + expect(queue.pendingQueue.value.map(item => item.text)).toEqual(['A', 'B', 'C']) + expect(inputText.value).toBe('new composer text') + } finally { + queue.cleanup() + } + }) + + it('preserves mixed queue order when an earlier cancellation fails', async () => { + const { wal } = memoryWal() + const writeWal = wal.put + const { inputText, queue, sessionKey } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { pendingInputWal: wal, hasRpcMethod: () => false }, + ) + try { + for (const text of ['A', 'C']) { + inputText.value = text + await expect(queue.enqueuePendingInput(text)).resolves.toBe(true) + } + await vi.waitFor(() => expect(queue.pendingQueue.value.every(item => ( + item.pendingPersistenceState === 'local_only' + ))).toBe(true)) + const firstId = queue.pendingQueue.value[0]!.pendingInputId! + queue.pendingQueue.value.splice(1, 0, { + pendingUiId: 'pending-server-B', + pendingInputId: 'pending-server-B', + pendingClientRequestId: 'request-server-B', + pendingClientMessageId: 'message-server-B', + pendingPersistenceState: 'staged', + pendingMayHaveServerCopy: true, + pendingWalRevision: 1, + pendingCreatedAt: 2, + ownerSessionKey: sessionKey.value, + text: 'B', + attachments: [{ + kind: 'staged', + local_id: 284, + name: 'server-B.txt', + mime: 'text/plain', + durable_material: true, + }], + intent: null, + }) + wal.put = vi.fn(async record => { + if (record.pendingInputId === firstId && record.state === 'cancelling') { + throw new Error('lost cancellation acknowledgement') + } + await writeWal(record) + }) + + expect(queue.popAllPendingIntoComposer()).toBe(true) + await vi.waitFor(() => expect(queue.pendingQueue.value[2]).toMatchObject({ + text: 'C', + pendingPersistenceState: 'local_only', + pendingRetainAfterCancel: true, + })) + expect(queue.pendingQueue.value.map(item => item.text)).toEqual(['A', 'B', 'C']) + expect(inputText.value).toBe('') + } finally { + queue.cleanup() + } + }) + it('keeps adjacent drafts ordered when different tabs win their retain CAS', async () => { vi.stubGlobal( 'BroadcastChannel', diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index e56fe6588..3b4b723d0 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -365,6 +365,21 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } as ChatPendingItem } + function replaceItemFromWalRecord( + item: ChatPendingItem, + record: PendingInputWalRecord, + ) { + delete item.promptAnnotationIds + delete item.confirmedPlainText + delete item.ownerRequestId + delete item.pendingRetainAfterCancel + delete item.pendingRequestFingerprint + delete item.pendingServerRevision + delete item.pendingPosition + delete item.deliveryState + Object.assign(item, itemFromWalRecord(record)) + } + function removedIdentity(sessionKey: string, pendingInputId: string): string { return `${queueSessionKey(sessionKey)}\u0000${pendingInputId}` } @@ -671,11 +686,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { for (const record of records) { const existing = existingById.get(record.pendingInputId) if (existing && record.retainAfterCancel === true) { - Object.assign(existing, itemFromWalRecord(record)) - delete existing.deliveryState - delete existing.pendingRequestFingerprint - delete existing.pendingServerRevision - delete existing.pendingPosition + replaceItemFromWalRecord(existing, record) continue } if ( @@ -1165,17 +1176,18 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (mutation.record) { // Another tab still owns a live row. Keep this queue slot as an // ordering barrier and allow a later hydrate to reconcile it. - forgetRemoval(sessionKey, item.pendingInputId!) + if ( + mutation.record.state !== 'cancelling' + || mutation.record.retainAfterCancel === true + ) { + forgetRemoval(sessionKey, item.pendingInputId!) + } if ( mutation.record.sessionKey === sessionKey && mutation.record.clientRequestId === item.pendingClientRequestId && mutation.record.clientMessageId === item.pendingClientMessageId ) { - Object.assign(item, itemFromWalRecord(mutation.record)) - delete item.deliveryState - delete item.pendingRequestFingerprint - delete item.pendingServerRevision - delete item.pendingPosition + replaceItemFromWalRecord(item, mutation.record) } return false } @@ -1694,6 +1706,25 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { )) } + function collisionFreeComposerAttachments( + attachments: Attachment[], + existing: Attachment[] = options.pendingAttachments.value, + ): Attachment[] { + const usedLocalIds = new Set(existing.map(attachment => attachment.local_id)) + let nextRecoveredLocalId = -1 + return attachments.map(attachment => { + if (!usedLocalIds.has(attachment.local_id)) { + usedLocalIds.add(attachment.local_id) + return attachment + } + while (usedLocalIds.has(nextRecoveredLocalId)) nextRecoveredLocalId -= 1 + const rekeyed = { ...attachment, local_id: nextRecoveredLocalId } + usedLocalIds.add(nextRecoveredLocalId) + nextRecoveredLocalId -= 1 + return rekeyed + }) + } + function restoreDurableItemIntoComposer( item: ChatPendingItem, restore: () => void, @@ -1790,7 +1821,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { : attachment )) options.pendingAttachments.value = [ - ...restoredAttachments, + ...collisionFreeComposerAttachments(restoredAttachments), ...options.pendingAttachments.value, ] options.pendingSessionIntent.value = ( @@ -1827,7 +1858,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (durableItem(tail)) { restoreDurableItemIntoComposer(tail, () => { options.inputText.value = tail.text || '' - options.pendingAttachments.value = tail.attachments || [] + options.pendingAttachments.value = collisionFreeComposerAttachments( + tail.attachments || [], + [], + ) options.pendingSessionIntent.value = tail.intent || null options.autoResizeTextarea() }) @@ -1835,7 +1869,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } pendingQueue.value.splice(tailIndex, 1) options.inputText.value = tail?.text || '' - options.pendingAttachments.value = tail?.attachments || [] + options.pendingAttachments.value = collisionFreeComposerAttachments( + tail?.attachments || [], + [], + ) options.pendingSessionIntent.value = tail?.intent || null options.autoResizeTextarea() return true @@ -1853,12 +1890,6 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { && !p.steerAttempt && !hasUneditablePendingAttachments(p), ) - const retained = pendingQueue.value.filter( - p => p.hiddenControl - || p.deliveryState - || p.steerAttempt - || hasUneditablePendingAttachments(p), - ) if (visible.length === 0) return false const immediate = visible.filter(item => !durableItem(item)) const durable = visible.filter(durableItem) @@ -1867,9 +1898,17 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const headIntent = immediate[0]?.intent const current = options.inputText.value || '' const joined = [current, ...queuedTexts].filter(Boolean).join('\n') - pendingQueue.value = [...retained, ...durable] + const immediateItems = new Set(immediate) + for (let index = pendingQueue.value.length - 1; index >= 0; index--) { + if (immediateItems.has(pendingQueue.value[index]!)) { + pendingQueue.value.splice(index, 1) + } + } options.inputText.value = joined - options.pendingAttachments.value = [...options.pendingAttachments.value, ...queuedAttachments] + options.pendingAttachments.value = [ + ...options.pendingAttachments.value, + ...collisionFreeComposerAttachments(queuedAttachments), + ] options.pendingSessionIntent.value = options.pendingSessionIntent.value || headIntent || null options.autoResizeTextarea() options.resetInputHistory() @@ -1884,7 +1923,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { .join('\n') options.pendingAttachments.value = [ ...options.pendingAttachments.value, - ...(item.attachments || []), + ...collisionFreeComposerAttachments(item.attachments || []), ] options.pendingSessionIntent.value = ( options.pendingSessionIntent.value || item.intent || null @@ -1974,7 +2013,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ) return pendingQueue.value.shift() options.inputText.value = head.text || '' - options.pendingAttachments.value = head.attachments || [] + options.pendingAttachments.value = collisionFreeComposerAttachments( + head.attachments || [], + [], + ) options.pendingSessionIntent.value = head.intent || null options.sendCurrentInput() }) From 07cd11ac97b0111fad110b544b6c3056c3e24a8d Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 21:00:20 +0800 Subject: [PATCH 13/18] Acquire queue cancellation atomically --- .../chat/useChatPendingQueue.test.ts | 80 +++++++++++++++++++ .../composables/chat/useChatPendingQueue.ts | 61 +++++++++++++- .../pendingInputWal.atomicHandoff.test.ts | 48 +++++++++++ .../src/utils/chat/pendingInputWal.ts | 43 +++++++++- 4 files changed, 227 insertions(+), 5 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 0f01a1df7..b8422af60 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -1203,6 +1203,86 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('does not recreate a tombstone when delayed cancellation acquisition loses to delete', async () => { + vi.stubGlobal( + 'BroadcastChannel', + TestBroadcastChannel as unknown as typeof BroadcastChannel, + ) + const { wal, records } = memoryWal() + const beginCancellation: NonNullable = vi.fn( + async (record, expectedWalRevision) => { + const current = records.get(record.pendingInputId) + if ( + !current + || current.sessionKey !== record.sessionKey + || current.clientRequestId !== record.clientRequestId + || current.clientMessageId !== record.clientMessageId + || (current.walRevision ?? 1) !== expectedWalRevision + ) { + return { + applied: false, + record: current ? structuredClone(current) : null, + } + } + const cancelling = { + ...structuredClone(record), + state: 'cancelling' as const, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + } + records.set(record.pendingInputId, cancelling) + return { applied: true, record: structuredClone(cancelling) } + }, + ) + let markDelayedBegin!: () => void + let releaseDelayedBegin!: () => void + const delayedBegin = new Promise(resolve => { markDelayedBegin = resolve }) + const delayedBeginGate = new Promise(resolve => { releaseDelayedBegin = resolve }) + const retainingWal: PendingInputWal = { + ...wal, + beginCancellation: vi.fn(async (record, expectedWalRevision) => { + markDelayedBegin() + await delayedBeginGate + return beginCancellation(record, expectedWalRevision) + }), + } + const destructiveWal: PendingInputWal = { ...wal, beginCancellation } + const retaining = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: retainingWal, + hasRpcMethod: () => false, + }) + const destructive = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: destructiveWal, + hasRpcMethod: () => false, + }) + try { + retaining.inputText.value = 'peer delete wins before cancellation acquisition' + await expect(retaining.queue.enqueuePendingInput(retaining.inputText.value)) + .resolves.toBe(true) + await destructive.queue.hydratePendingQueue() + + expect(retaining.queue.editPendingItem(pendingUiId(retaining.queue, 0))).toBe(true) + await delayedBegin + expect(destructive.queue.removePendingChip(pendingUiId(destructive.queue, 0))).toBe(true) + await vi.waitFor(() => expect(records.size).toBe(0)) + + releaseDelayedBegin() + await vi.waitFor(() => { + expect(retaining.queue.pendingQueue.value).toEqual([]) + expect(destructive.queue.pendingQueue.value).toEqual([]) + expect(records.size).toBe(0) + }) + expect(retaining.inputText.value).toBe('') + await retaining.queue.hydratePendingQueue() + expect(retaining.queue.pendingQueue.value).toEqual([]) + } finally { + retaining.queue.cleanup() + destructive.queue.cleanup() + vi.unstubAllGlobals() + TestBroadcastChannel.channels.clear() + } + }) + it('does not turn a peer-owned destructive tombstone back into a retained draft', async () => { const { wal, records } = memoryWal() const sharedRetain = wal.retainCancelled! diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 3b4b723d0..1e8c59314 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -1244,15 +1244,72 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (retainAfterCancel) item.pendingRetainAfterCancel = true else delete item.pendingRetainAfterCancel rememberRemoval(sessionKey, item.pendingInputId!) + const usesAtomicCancellation = Boolean(options.pendingInputWal?.beginCancellation) try { - await writeWalItem(item, 'cancelling') + if (options.pendingInputWal?.beginCancellation) { + let transition: 'applied' | 'missing' | 'conflict' = 'conflict' + for (let attempt = 0; attempt < 3; attempt++) { + if (retainAfterCancel) item.pendingRetainAfterCancel = true + else delete item.pendingRetainAfterCancel + const expectedWalRevision = item.pendingWalRevision ?? 1 + const mutation = await options.pendingInputWal.beginCancellation( + { + ...walRecordForItem(item, 'cancelling'), + walRevision: expectedWalRevision + 1, + }, + expectedWalRevision, + ) + if (mutation.applied) { + item.pendingPersistenceState = mutation.record!.state + item.pendingWalRevision = mutation.record!.walRevision + transition = 'applied' + break + } + if (!mutation.record) { + removePendingIdentity(sessionKey, item.pendingInputId!) + transition = 'missing' + break + } + const sameIdentity = ( + mutation.record.sessionKey === sessionKey + && mutation.record.clientRequestId === item.pendingClientRequestId + && mutation.record.clientMessageId === item.pendingClientMessageId + ) + if ( + mutation.record.state !== 'cancelling' + || mutation.record.retainAfterCancel === true + ) { + forgetRemoval(sessionKey, item.pendingInputId!) + } + if (sameIdentity) replaceItemFromWalRecord(item, mutation.record) + if ( + !retainAfterCancel + && sameIdentity + && mutation.record.state === 'cancelling' + && mutation.record.retainAfterCancel !== true + ) { + transition = 'applied' + break + } + if (!retainAfterCancel && sameIdentity) { + rememberRemoval(sessionKey, item.pendingInputId!) + continue + } + transition = 'conflict' + break + } + if (transition !== 'applied') return transition === 'missing' && !retainAfterCancel + } else { + await writeWalItem(item, 'cancelling') + } } catch { // The delete intent never became durable, so the composer/queue must keep // owning the previous state. No cancellation RPC was sent. forgetRemoval(sessionKey, item.pendingInputId!) if (previousRetainAfterCancel) item.pendingRetainAfterCancel = true else delete item.pendingRetainAfterCancel - await writeWalItem(item, previousState).catch(() => {}) + if (usesAtomicCancellation) item.pendingPersistenceState = previousState + else await writeWalItem(item, previousState).catch(() => {}) broadcastChange(sessionKey, item.pendingInputId, 'changed') return false } diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index fb9cc9ba3..543dada42 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -270,6 +270,54 @@ class ControlledObjectStore { } describe('BrowserPendingInputWal atomic mutations', () => { + it('acquires a cancelling tombstone only from the expected live revision', async () => { + const factory = new ControlledIdbFactory() + const wal = createPendingInputWal(factory.idbFactory) + expect(wal).not.toBeNull() + const localOnly: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId: 'pending-cancel-cas', + sessionKey: 'agent:main:webchat:cancel-cas', + clientRequestId: 'request-cancel-cas', + clientMessageId: 'message-cancel-cas', + text: 'cancel only while this row is live', + attachments: [], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const cancelling = { + ...localOnly, + state: 'cancelling' as const, + retainAfterCancel: true, + walRevision: 2, + } + await wal!.put(localOnly) + + await expect(wal!.beginCancellation!(cancelling, 1)).resolves.toMatchObject({ + applied: true, + record: { + state: 'cancelling', + retainAfterCancel: true, + walRevision: 2, + }, + }) + await expect(wal!.beginCancellation!(cancelling, 1)).resolves.toMatchObject({ + applied: false, + record: { state: 'cancelling', walRevision: 2 }, + }) + await wal!.delete(localOnly.pendingInputId) + await expect(wal!.beginCancellation!(cancelling, 2)).resolves.toEqual({ + applied: false, + record: null, + }) + expect(factory.record(PENDING_STORE, localOnly.pendingInputId)).toBeUndefined() + wal!.close() + }) + it('does not retain a cancelled draft after another owner deletes its WAL row', async () => { const factory = new ControlledIdbFactory() const wal = createPendingInputWal(factory.idbFactory) diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index c6cb4bca5..c8439dad0 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -69,7 +69,7 @@ export interface PendingInputOrderCommit { records: PendingInputWalRecord[] } -export interface PendingInputRetainMutation { +export interface PendingInputWalMutation { applied: boolean /** The current live row, or null only when no valid row owns this identity. */ record: PendingInputWalRecord | null @@ -90,11 +90,16 @@ export interface PendingInputWal { list: (sessionKey: string) => Promise delete: (pendingInputId: string) => Promise putMany?: (records: PendingInputWalRecord[]) => Promise + /** Acquire a cancellation tombstone only while the exact WAL revision still owns the row. */ + beginCancellation?: ( + record: PendingInputWalRecord, + expectedWalRevision: number, + ) => Promise /** Convert one cancelling tombstone into a retained local draft only while its exact WAL revision still owns the row. */ retainCancelled?: ( record: PendingInputWalRecord, expectedWalRevision: number, - ) => Promise + ) => Promise commitOrder?: ( sessionKey: string, orderedIds: string[], @@ -335,10 +340,42 @@ class BrowserPendingInputWal implements PendingInputWal { await transactionDone(transaction) } + async beginCancellation( + record: PendingInputWalRecord, + expectedWalRevision: number, + ): Promise { + const database = await this.database() + const transaction = database.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const raw = await requestResult(store.get(record.pendingInputId)) + if ( + !isPendingInputWalRecord(raw) + || raw.sessionKey !== record.sessionKey + || raw.clientRequestId !== record.clientRequestId + || raw.clientMessageId !== record.clientMessageId + || (raw.walRevision ?? 1) !== expectedWalRevision + ) { + await transactionDone(transaction) + return { + applied: false, + record: isPendingInputWalRecord(raw) ? cloneRecord(raw) : null, + } + } + const cancelling = cloneRecord({ + ...record, + state: 'cancelling', + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + }) + store.put(cancelling) + await transactionDone(transaction) + return { applied: true, record: cancelling } + } + async retainCancelled( record: PendingInputWalRecord, expectedWalRevision: number, - ): Promise { + ): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readwrite') const store = transaction.objectStore(STORE_NAME) From ceb5abaf69af7c9c743d9b1ef029e5a7daafb100 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 21:17:10 +0800 Subject: [PATCH 14/18] Fence queue cancellation continuations --- .../chat/useChatPendingQueue.test.ts | 311 +++++++++++++++++- .../composables/chat/useChatPendingQueue.ts | 140 ++++++-- .../chat/useChatSend.attachments.test.ts | 38 +++ .../src/composables/chat/useChatSend.ts | 13 +- .../pendingInputWal.atomicHandoff.test.ts | 46 +++ .../src/utils/chat/pendingInputWal.ts | 46 ++- 6 files changed, 566 insertions(+), 28 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index b8422af60..0472da325 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -211,6 +211,46 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { return { records, handoffs, wal } } +function enableAtomicPendingMutations( + wal: PendingInputWal, + records: Map, +) { + const compareAndSwap: NonNullable = vi.fn( + async (record, expectedWalRevision, equivalentSessionKeys = []) => { + const current = records.get(record.pendingInputId) + const sessionKeys = new Set([record.sessionKey, ...equivalentSessionKeys]) + if ( + !current + || !sessionKeys.has(current.sessionKey) + || current.clientRequestId !== record.clientRequestId + || current.clientMessageId !== record.clientMessageId + || (current.walRevision ?? 1) !== expectedWalRevision + ) { + return { + applied: false, + record: current ? structuredClone(current) : null, + } + } + const updated = { + ...structuredClone(record), + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + } + records.set(record.pendingInputId, updated) + return { applied: true, record: structuredClone(updated) } + }, + ) + wal.compareAndSwapPendingInput = compareAndSwap + wal.beginCancellation = vi.fn(async (record, expectedWalRevision, equivalentSessionKeys) => ( + compareAndSwap( + { ...record, state: 'cancelling' }, + expectedWalRevision, + equivalentSessionKeys, + ) + )) + return compareAndSwap +} + class TestBroadcastChannel { static readonly channels = new Map>() @@ -661,6 +701,120 @@ describe('useChatPendingQueue delivery state', () => { queue.cleanup() }) + it('does not restage an attachment after removal during delayed preparation', async () => { + const { wal, records } = memoryWal() + enableAtomicPendingMutations(wal, records) + let markPreparationStarted!: () => void + let releasePreparation!: () => void + const preparationStarted = new Promise(resolve => { markPreparationStarted = resolve }) + const preparationGate = new Promise(resolve => { releasePreparation = resolve }) + const prepareAttachmentsForSend = vi.fn(async () => { + markPreparationStarted() + await preparationGate + return true + }) + const rpcCall = vi.fn(async (method: string): Promise => { + if (method === 'sessions.pending_inputs.list') return { items: [] } + if (method === 'sessions.pending_inputs.cancel') return { cancelled: true } + throw new Error(`unexpected method: ${method}`) + }) + const rpc: LegacyQueueRpc = { + call: (method: string, params?: Record) => { + void params + return rpcCall(method) as Promise + }, + } + const { inputText, pendingAttachments, queue } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { + pendingInputWal: wal, + rpc, + hasRpcMethod: method => method.startsWith('sessions.pending_inputs.'), + prepareAttachmentsForSend, + }, + ) + try { + inputText.value = 'remove during attachment preparation' + pendingAttachments.value = [{ + kind: 'staged', + local_id: 11, + name: 'delayed.txt', + mime: 'text/plain', + file_uuid: 'delayed-upload', + file: new File(['delayed'], 'delayed.txt', { type: 'text/plain' }), + }] + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await preparationStarted + + expect(queue.removePendingChip(pendingUiId(queue, 0))).toBe(true) + await vi.waitFor(() => expect(records.size).toBe(0)) + releasePreparation() + + await vi.waitFor(() => expect(queue.pendingQueue.value).toEqual([])) + expect(records.size).toBe(0) + expect(rpcCall.mock.calls.map(call => call[0])) + .not.toContain('sessions.pending_inputs.enqueue') + } finally { + queue.cleanup() + } + }) + + it('does not rewrite saving after removal wins an unknown enqueue response', async () => { + const { wal, records } = memoryWal() + enableAtomicPendingMutations(wal, records) + let markEnqueueStarted!: () => void + let rejectEnqueue!: (error: unknown) => void + const enqueueStarted = new Promise(resolve => { markEnqueueStarted = resolve }) + const enqueueResult = new Promise((_resolve, reject) => { rejectEnqueue = reject }) + const rpcCall = vi.fn((method: string): Promise => { + if (method === 'sessions.pending_inputs.list') return Promise.resolve({ items: [] }) + if (method === 'sessions.pending_inputs.cancel') { + return Promise.resolve({ cancelled: true }) + } + if (method === 'sessions.pending_inputs.enqueue') { + markEnqueueStarted() + return enqueueResult + } + return Promise.reject(new Error(`unexpected method: ${method}`)) + }) + const rpc: LegacyQueueRpc = { + call: (method: string, params?: Record) => { + void params + return rpcCall(method) as Promise + }, + } + const { inputText, queue } = makeQueue( + undefined, + () => false, + undefined, + undefined, + { + pendingInputWal: wal, + rpc, + hasRpcMethod: method => method.startsWith('sessions.pending_inputs.'), + }, + ) + try { + inputText.value = 'remove during unknown enqueue response' + await expect(queue.enqueuePendingInput(inputText.value)).resolves.toBe(true) + await enqueueStarted + + expect(queue.removePendingChip(pendingUiId(queue, 0))).toBe(true) + await vi.waitFor(() => expect(records.size).toBe(0)) + rejectEnqueue(new Error('connection closed after request')) + + await vi.waitFor(() => expect(queue.pendingQueue.value).toEqual([])) + expect(records.size).toBe(0) + await queue.hydratePendingQueue() + expect(queue.pendingQueue.value).toEqual([]) + } finally { + queue.cleanup() + } + }) + it('retries an unknown enqueue result with the same durable identities', async () => { const { wal } = memoryWal() const enqueueCalls: Record[] = [] @@ -1986,6 +2140,67 @@ describe('useChatPendingQueue delivery state', () => { } }) + it.each([ + 'editPendingItem', + 'popAllPendingIntoComposer', + ] as const)( + 'trash invalidates an in-flight %s restore before destructive cleanup', + async recoveryPath => { + const { wal, records } = memoryWal() + const retainCancelled = wal.retainCancelled! + let releaseRetain!: () => void + let markRetainStarted!: () => void + const retainStarted = new Promise(resolve => { markRetainStarted = resolve }) + const retainGate = new Promise(resolve => { releaseRetain = resolve }) + wal.retainCancelled = vi.fn(async (record, expectedWalRevision) => { + markRetainStarted() + await retainGate + return retainCancelled(record, expectedWalRevision) + }) + const initial = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + initial.inputText.value = `trash ${recoveryPath} restore` + await expect(initial.queue.enqueuePendingInput(initial.inputText.value)) + .resolves.toBe(true) + await vi.waitFor(() => { + expect(initial.queue.pendingQueue.value[0]?.pendingPersistenceState) + .toBe('local_only') + }) + const pendingId = pendingUiId(initial.queue, 0) + + const restoring = recoveryPath === 'editPendingItem' + ? initial.queue.editPendingItem(pendingId) + : initial.queue.popAllPendingIntoComposer() + expect(restoring).toBe(true) + await retainStarted + expect(initial.queue.removePendingChip(pendingId)).toBe(true) + releaseRetain() + + await vi.waitFor(() => { + expect(initial.queue.pendingQueue.value).toEqual([]) + expect(records.size).toBe(0) + }) + expect(initial.inputText.value).toBe('') + + const reloaded = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await reloaded.queue.hydratePendingQueue() + expect(reloaded.queue.pendingQueue.value).toEqual([]) + } finally { + reloaded.queue.cleanup() + } + } finally { + initial.queue.cleanup() + } + }, + ) + it('retains delayed composer recovery in the WAL after cleanup for the next hydrate', async () => { const { wal, records } = memoryWal() const writeWal = wal.put @@ -2121,7 +2336,7 @@ describe('useChatPendingQueue delivery state', () => { it('hydrates and parks a legacy alias WAL row under its canonical queue owner', async () => { const legacySession = 'agent:default:webchat:alias-draft' const canonicalSession = 'agent:main:webchat:alias-draft' - const { wal } = memoryWal([{ + const { wal, records } = memoryWal([{ schemaVersion: 1, pendingInputId: 'pending-alias-draft', sessionKey: legacySession, @@ -2141,6 +2356,7 @@ describe('useChatPendingQueue delivery state', () => { createdAt: 1, updatedAt: 2, }]) + enableAtomicPendingMutations(wal, records) const sessionKey = ref(canonicalSession) const { queue } = makeQueue(undefined, () => false, undefined, undefined, { sessionKey, @@ -2166,6 +2382,17 @@ describe('useChatPendingQueue delivery state', () => { pendingInputId: 'pending-alias-draft', ownerSessionKey: canonicalSession, }]) + + expect(queue.editPendingItem(pendingUiId(queue, 0))).toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value).toEqual([]) + expect(records.size).toBe(0) + }) + expect(wal.beginCancellation).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: canonicalSession }), + 1, + expect.arrayContaining([legacySession, canonicalSession]), + ) } finally { queue.cleanup() } @@ -2349,6 +2576,88 @@ describe('useChatPendingQueue delivery state', () => { queue.cleanup() }) + it('does not let an in-flight server hydrate overwrite a peer cancellation tombstone', async () => { + const pendingInputId = 'pending-peer-cancel-during-hydrate' + const initialRecord: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId, + sessionKey: 'agent:main:webchat:test', + clientRequestId: 'request-peer-cancel-during-hydrate', + clientMessageId: 'message-peer-cancel-during-hydrate', + text: 'peer cancellation must win', + attachments: [], + intent: null, + state: 'staged', + mayHaveServerCopy: true, + requestFingerprint: 'sha256:peer-cancel-during-hydrate', + serverRevision: 4, + walRevision: 1, + createdAt: 1, + updatedAt: 2, + } + const { wal, records } = memoryWal([initialRecord]) + enableAtomicPendingMutations(wal, records) + let markListStarted!: () => void + let releaseList!: () => void + const listStarted = new Promise(resolve => { markListStarted = resolve }) + const listGate = new Promise(resolve => { releaseList = resolve }) + const pendingInputQueue: PendingInputQueuePort = { + supportsQueue: () => true, + supportsReorder: () => false, + enqueue: vi.fn(async () => ({ + requestFingerprint: initialRecord.requestFingerprint!, + revision: 4, + position: 0, + })), + list: vi.fn(async () => { + markListStarted() + await listGate + return [{ + pendingInputId, + clientRequestId: initialRecord.clientRequestId, + clientMessageId: initialRecord.clientMessageId, + message: initialRecord.text, + position: 0, + revision: 4, + requestFingerprint: initialRecord.requestFingerprint!, + }] + }), + cancel: vi.fn(async () => { + throw new Error('keep the tombstone for inspection') + }), + reorder: vi.fn(async () => ({ items: [] })), + } + const { queue } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + pendingInputQueue, + }) + try { + await listStarted + records.set(pendingInputId, { + ...initialRecord, + state: 'cancelling', + walRevision: 2, + updatedAt: 3, + }) + releaseList() + + await vi.waitFor(() => { + expect(queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('cancelling') + }) + expect(records.get(pendingInputId)).toMatchObject({ + state: 'cancelling', + walRevision: 2, + }) + expect(wal.compareAndSwapPendingInput).toHaveBeenCalledWith( + expect.objectContaining({ state: 'staged' }), + 1, + expect.any(Array), + ) + } finally { + queue.cleanup() + } + }) + it('retains a reclassified draft when the cancel acknowledgement is lost', async () => { const { wal, records } = memoryWal([{ schemaVersion: 1, diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 1e8c59314..7c9d786b1 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -101,6 +101,8 @@ export interface PendingQueueOwner { export interface PendingCancelOptions { retainAfterCancel?: boolean + /** Internal post-restore cleanup must not invalidate sibling restores. */ + invalidateRestore?: boolean } export interface PendingQueueOwnerContext { @@ -380,6 +382,16 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { Object.assign(item, itemFromWalRecord(record)) } + function walRecordOwnsItem( + record: PendingInputWalRecord, + item: ChatPendingItem, + sessionKey: string, + ): boolean { + return queueSessionKey(record.sessionKey) === queueSessionKey(sessionKey) + && record.clientRequestId === item.pendingClientRequestId + && record.clientMessageId === item.pendingClientMessageId + } + function removedIdentity(sessionKey: string, pendingInputId: string): string { return `${queueSessionKey(sessionKey)}\u0000${pendingInputId}` } @@ -469,6 +481,53 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } } + async function writeStagingWalItem( + item: ChatPendingItem, + state: PendingInputWalState, + sessionKey: string, + ): Promise { + const pendingInputId = item.pendingInputId + const wal = options.pendingInputWal + if ( + !pendingInputId + || !wal + || wasRemoved(sessionKey, pendingInputId) + || item.pendingPersistenceState === 'cancelling' + || item.pendingRetainAfterCancel === true + ) return false + if (!wal.compareAndSwapPendingInput) { + await writeWalItem(item, state) + return !wasRemoved(sessionKey, pendingInputId) + } + const expectedWalRevision = item.pendingWalRevision ?? 1 + const mutation = await wal.compareAndSwapPendingInput( + { + ...walRecordForItem(item, state), + walRevision: expectedWalRevision + 1, + }, + expectedWalRevision, + walLookupSessionKeys(sessionKey), + ) + if (mutation.applied) { + item.pendingPersistenceState = mutation.record!.state + item.pendingWalRevision = mutation.record!.walRevision + return true + } + if (!mutation.record) { + rememberRemoval(sessionKey, pendingInputId) + removePendingIdentity(sessionKey, pendingInputId) + return false + } + if (walRecordOwnsItem(mutation.record, item, sessionKey)) { + replaceItemFromWalRecord(item, mutation.record) + if ( + mutation.record.state === 'cancelling' + && mutation.record.retainAfterCancel !== true + ) rememberRemoval(sessionKey, pendingInputId) + } + return false + } + function ordinaryDurableItem(item: ChatPendingItem): boolean { return Boolean( item.pendingInputId @@ -535,7 +594,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const operation = (async () => { if (wasRemoved(sessionKey, pendingInputId)) return if (!supportsServerQueue()) { - await writeWalItem(item, 'local_only') + await writeStagingWalItem(item, 'local_only', sessionKey) return } let refreshedLostUpload = false @@ -550,19 +609,24 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { )), ownership: 'detached', }) + if ( + wasRemoved(sessionKey, pendingInputId) + || item.pendingPersistenceState === 'cancelling' + || item.pendingRetainAfterCancel === true + ) return if (!ready) { - await writeWalItem(item, 'retryable') + if (!await writeStagingWalItem(item, 'retryable', sessionKey)) return options.onPendingPersistenceError?.('server_rejected') return } // Persist refreshed upload UUIDs before the request can become // ambiguous. A reload then retries the same material snapshot. - await writeWalItem(item, 'saving') + if (!await writeStagingWalItem(item, 'saving', sessionKey)) return } if (wasRemoved(sessionKey, pendingInputId)) return const sendable = item.attachments.filter(isSendableAttachment) if (sendable.length !== item.attachments.length) { - await writeWalItem(item, 'retryable') + if (!await writeStagingWalItem(item, 'retryable', sessionKey)) return options.onPendingPersistenceError?.('server_rejected') return } @@ -576,7 +640,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { // lost ACK can then distinguish this identity from a genuinely // IndexedDB-only draft when the next Gateway is older/offline. item.pendingMayHaveServerCopy = true - await writeWalItem(item, 'saving') + if (!await writeStagingWalItem(item, 'saving', sessionKey)) return const response = await pendingInputQueue!.enqueue({ key: queueSessionKey(item.ownerSessionKey), pendingInputId, @@ -618,7 +682,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { // Drop base64, File objects and expiring upload capabilities from // IndexedDB; dispatch now needs only the stable pending identity. item.attachments = item.attachments.map(durableAttachmentMetadata) - await writeWalItem(item, 'staged') + if (!await writeStagingWalItem(item, 'staged', sessionKey)) return flushDeferredPendingDrain() return } catch (error) { @@ -656,19 +720,25 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { broadcastChange(sessionKey, pendingInputId, 'removed') return } + if ( + wasRemoved(sessionKey, pendingInputId) + || item.pendingPersistenceState === 'cancelling' + || item.pendingRetainAfterCancel === true + ) return if (code === 'METHOD_NOT_FOUND') { - await writeWalItem(item, 'local_only') - flushDeferredPendingDrain() + if (await writeStagingWalItem(item, 'local_only', sessionKey)) { + flushDeferredPendingDrain() + } return } if ((error as { accepted?: unknown } | null)?.accepted === false) { - await writeWalItem(item, 'retryable').catch(() => {}) + await writeStagingWalItem(item, 'retryable', sessionKey).catch(() => false) options.onPendingPersistenceError?.('server_rejected') return } // Unknown transport results deliberately remain "saving". Reconnect, // hydrate, or another tab retries this exact identity byte-for-byte. - await writeWalItem(item, 'saving').catch(() => {}) + await writeStagingWalItem(item, 'saving', sessionKey).catch(() => false) } })().finally(() => { stagingOperations.delete(pendingInputId) @@ -685,7 +755,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ) for (const record of records) { const existing = existingById.get(record.pendingInputId) - if (existing && record.retainAfterCancel === true) { + if ( + existing + && (record.retainAfterCancel === true || record.state === 'cancelling') + ) { replaceItemFromWalRecord(existing, record) continue } @@ -857,7 +930,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ? serverItem.position : item.pendingPosition item.pendingMayHaveServerCopy = true - await writeWalItem(item, 'staged') + await writeStagingWalItem(item, 'staged', ownerSessionKey) } sortOrdinaryPendingItems() @@ -1171,6 +1244,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { walRevision: expectedWalRevision + 1, }, expectedWalRevision, + walLookupSessionKeys(sessionKey), ) if (!mutation.applied) { if (mutation.record) { @@ -1182,11 +1256,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ) { forgetRemoval(sessionKey, item.pendingInputId!) } - if ( - mutation.record.sessionKey === sessionKey - && mutation.record.clientRequestId === item.pendingClientRequestId - && mutation.record.clientMessageId === item.pendingClientMessageId - ) { + if (walRecordOwnsItem(mutation.record, item, sessionKey)) { replaceItemFromWalRecord(item, mutation.record) } return false @@ -1258,6 +1328,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { walRevision: expectedWalRevision + 1, }, expectedWalRevision, + walLookupSessionKeys(sessionKey), ) if (mutation.applied) { item.pendingPersistenceState = mutation.record!.state @@ -1270,11 +1341,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { transition = 'missing' break } - const sameIdentity = ( - mutation.record.sessionKey === sessionKey - && mutation.record.clientRequestId === item.pendingClientRequestId - && mutation.record.clientMessageId === item.pendingClientMessageId - ) + const sameIdentity = walRecordOwnsItem(mutation.record, item, sessionKey) if ( mutation.record.state !== 'cancelling' || mutation.record.retainAfterCancel === true @@ -1370,6 +1437,14 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { const pendingInputId = item.pendingInputId if (!pendingInputId || !options.pendingInputWal) return Promise.resolve(true) const retainAfterCancel = cancelOptions.retainAfterCancel === true + if ( + !retainAfterCancel + && cancelOptions.invalidateRestore !== false + && item.pendingRetainAfterCancel === true + ) { + invalidateCancellation(pendingInputId) + activeQueueLease += 1 + } const existing = cancellationOperations.get(pendingInputId) if (existing) { if (!existing.retainAfterCancel || retainAfterCancel) return existing.promise @@ -1789,6 +1864,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ownerSessionKey: queueSessionKey(item.ownerSessionKey), queueLease: activeQueueLease, composerRevision, + cancellationInvalidation: item.pendingInputId + ? cancellationInvalidations.get(item.pendingInputId) ?? 0 + : 0, }, ) { void cancelDurableItem(item, { retainAfterCancel: true }).then(retained => { @@ -1799,6 +1877,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { || queueSessionKey() !== lease.ownerSessionKey || activeQueueLease !== lease.queueLease || composerRevision !== lease.composerRevision + || (item.pendingInputId + && (cancellationInvalidations.get(item.pendingInputId) ?? 0) + !== lease.cancellationInvalidation) ) return const index = pendingQueue.value.indexOf(item) if (index < 0) return @@ -1808,7 +1889,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { // The composer now owns the retained local-only payload. Remove its WAL // record without reopening a window where a navigation can restore the // source item into a different session's composer. - void cancelDurableItem(item) + void cancelDurableItem(item, { invalidateRestore: false }) }) } @@ -1819,6 +1900,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ownerSessionKey: string queueLease: number composerRevision: number + cancellationInvalidations: Map }, ) { void Promise.all(items.map(item => ( @@ -1831,6 +1913,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { || composerRevision !== lease.composerRevision ) return for (const [index, item] of items.entries()) { + if ( + item.pendingInputId + && (cancellationInvalidations.get(item.pendingInputId) ?? 0) + !== (lease.cancellationInvalidations.get(item.pendingInputId) ?? 0) + ) break const queueIndex = pendingQueue.value.indexOf(item) if (!retainedItems[index]) { // A failed predecessor that still owns a queue slot is an ordering @@ -1844,7 +1931,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { restore(item) lease.composerRevision = composerRevision // Each retained row is removed only after its ordered composer commit. - void cancelDurableItem(item) + void cancelDurableItem(item, { invalidateRestore: false }) } }) } @@ -1973,6 +2060,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ownerSessionKey: queueSessionKey(), queueLease: activeQueueLease, composerRevision, + cancellationInvalidations: new Map(durable.flatMap(item => ( + item.pendingInputId + ? [[item.pendingInputId, cancellationInvalidations.get(item.pendingInputId) ?? 0]] + : [] + ))), } restoreDurableItemsIntoComposerInOrder(durable, item => { options.inputText.value = [options.inputText.value, item.text] diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 4c5e8e50e..77bebdf2b 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -3428,6 +3428,44 @@ describe('useChatSend attachment payloads', () => { expect(pendingForkBeforeMessageId.value).toBeNull() }) + it('re-keys a rejected attempt attachment that collides with a newer draft', async () => { + const attempted: Attachment = { + kind: 'staged', + local_id: 1, + name: 'attempted.pdf', + mime: 'application/pdf', + file_uuid: 'file-attempted', + } + const newer: Attachment = { + kind: 'staged', + local_id: 1, + name: 'newer.pdf', + mime: 'application/pdf', + file_uuid: 'file-newer', + } + let rejectSend!: (error: unknown) => void + const rpc = { + call: vi.fn(() => new Promise((_resolve, reject) => { rejectSend = reject })), + } + const inputText = ref('attempt this send') + const pendingAttachments = ref([attempted]) + const { api } = makeOptions({ rpc, inputText, pendingAttachments }) + + const sending = api.onSend() + await vi.waitFor(() => expect(rpc.call).toHaveBeenCalled()) + inputText.value = 'newer composer draft' + pendingAttachments.value = [newer] + rejectSend(Object.assign(new Error('database busy'), { accepted: false })) + await sending + + expect(pendingAttachments.value.map(attachment => attachment.name)) + .toEqual(['attempted.pdf', 'newer.pdf']) + expect(pendingAttachments.value.map(attachment => attachment.local_id)) + .toEqual([-1, 1]) + expect(new Set(pendingAttachments.value.map(attachment => attachment.local_id)).size) + .toBe(2) + }) + it('moves an ambiguous v2 steer into an exact-id retry instead of resending as follow-up', async () => { const inputText = ref('steer this exact turn') const rpc = { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index b7c588194..6a618b26c 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -3596,6 +3596,10 @@ export function useChatSend(options: UseChatSendOptions) { ) { if (attachments.length === 0) return const additions: Array<{ attachment: SendableAttachment, owner: string }> = [] + const usedLocalIds = new Set( + options.pendingAttachments.value.map(attachment => attachment.local_id), + ) + let nextRecoveredLocalId = -1 for (const [index, attachment] of attachments.entries()) { const owner = responseHandoffAttachmentOwner(ownerRequestId, index) const current = options.pendingAttachments.value.find(candidate => ( @@ -3607,7 +3611,14 @@ export function useChatSend(options: UseChatSendOptions) { if (current) { responseHandoffAttachmentOwners.set(current, owner) } else { - additions.push({ attachment, owner }) + let restored = attachment + if (usedLocalIds.has(restored.local_id)) { + while (usedLocalIds.has(nextRecoveredLocalId)) nextRecoveredLocalId -= 1 + restored = { ...restored, local_id: nextRecoveredLocalId } + nextRecoveredLocalId -= 1 + } + usedLocalIds.add(restored.local_id) + additions.push({ attachment: restored, owner }) } } if (additions.length > 0) { diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index 543dada42..312f3b20c 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -318,6 +318,52 @@ describe('BrowserPendingInputWal atomic mutations', () => { wal!.close() }) + it('acquires and retains a canonical cancellation from a legacy alias row', async () => { + const factory = new ControlledIdbFactory() + const wal = createPendingInputWal(factory.idbFactory) + expect(wal).not.toBeNull() + const legacySession = 'agent:default:webchat:cancel-alias' + const canonicalSession = 'agent:main:webchat:cancel-alias' + const legacy: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId: 'pending-cancel-alias', + sessionKey: legacySession, + clientRequestId: 'request-cancel-alias', + clientMessageId: 'message-cancel-alias', + text: 'cancel the legacy alias row', + attachments: [], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const cancelling = { + ...legacy, + sessionKey: canonicalSession, + state: 'cancelling' as const, + retainAfterCancel: true, + walRevision: 2, + } + await wal!.put(legacy) + + await expect(wal!.beginCancellation!(cancelling, 1, [legacySession])) + .resolves.toMatchObject({ + applied: true, + record: { sessionKey: canonicalSession, state: 'cancelling', walRevision: 2 }, + }) + await expect(wal!.retainCancelled!({ + ...cancelling, + state: 'local_only', + walRevision: 3, + }, 2, [legacySession])).resolves.toMatchObject({ + applied: true, + record: { sessionKey: canonicalSession, state: 'local_only', walRevision: 3 }, + }) + wal!.close() + }) + it('does not retain a cancelled draft after another owner deletes its WAL row', async () => { const factory = new ControlledIdbFactory() const wal = createPendingInputWal(factory.idbFactory) diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index c8439dad0..2aad5b09a 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -94,11 +94,19 @@ export interface PendingInputWal { beginCancellation?: ( record: PendingInputWalRecord, expectedWalRevision: number, + equivalentSessionKeys?: string[], ) => Promise /** Convert one cancelling tombstone into a retained local draft only while its exact WAL revision still owns the row. */ retainCancelled?: ( record: PendingInputWalRecord, expectedWalRevision: number, + equivalentSessionKeys?: string[], + ) => Promise + /** Replace a live pending row only while its exact identity and WAL revision still match. */ + compareAndSwapPendingInput?: ( + record: PendingInputWalRecord, + expectedWalRevision: number, + equivalentSessionKeys?: string[], ) => Promise commitOrder?: ( sessionKey: string, @@ -343,6 +351,7 @@ class BrowserPendingInputWal implements PendingInputWal { async beginCancellation( record: PendingInputWalRecord, expectedWalRevision: number, + equivalentSessionKeys: string[] = [], ): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readwrite') @@ -350,7 +359,7 @@ class BrowserPendingInputWal implements PendingInputWal { const raw = await requestResult(store.get(record.pendingInputId)) if ( !isPendingInputWalRecord(raw) - || raw.sessionKey !== record.sessionKey + || !new Set([record.sessionKey, ...equivalentSessionKeys]).has(raw.sessionKey) || raw.clientRequestId !== record.clientRequestId || raw.clientMessageId !== record.clientMessageId || (raw.walRevision ?? 1) !== expectedWalRevision @@ -375,6 +384,7 @@ class BrowserPendingInputWal implements PendingInputWal { async retainCancelled( record: PendingInputWalRecord, expectedWalRevision: number, + equivalentSessionKeys: string[] = [], ): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readwrite') @@ -382,7 +392,7 @@ class BrowserPendingInputWal implements PendingInputWal { const raw = await requestResult(store.get(record.pendingInputId)) if ( !isPendingInputWalRecord(raw) - || raw.sessionKey !== record.sessionKey + || !new Set([record.sessionKey, ...equivalentSessionKeys]).has(raw.sessionKey) || raw.clientRequestId !== record.clientRequestId || raw.clientMessageId !== record.clientMessageId || raw.state !== 'cancelling' @@ -407,6 +417,38 @@ class BrowserPendingInputWal implements PendingInputWal { return { applied: true, record: retained } } + async compareAndSwapPendingInput( + record: PendingInputWalRecord, + expectedWalRevision: number, + equivalentSessionKeys: string[] = [], + ): Promise { + const database = await this.database() + const transaction = database.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const raw = await requestResult(store.get(record.pendingInputId)) + if ( + !isPendingInputWalRecord(raw) + || !new Set([record.sessionKey, ...equivalentSessionKeys]).has(raw.sessionKey) + || raw.clientRequestId !== record.clientRequestId + || raw.clientMessageId !== record.clientMessageId + || (raw.walRevision ?? 1) !== expectedWalRevision + ) { + await transactionDone(transaction) + return { + applied: false, + record: isPendingInputWalRecord(raw) ? cloneRecord(raw) : null, + } + } + const updated = cloneRecord({ + ...record, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + }) + store.put(updated) + await transactionDone(transaction) + return { applied: true, record: updated } + } + async list(sessionKey: string): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readonly') From 03cf221ee03a88e6ad2d38d7e13fe3302eb1d6f3 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 22:35:36 +0800 Subject: [PATCH 15/18] Preserve queue cancellation across handoff --- .../chat/useChatPendingQueue.test.ts | 441 +++++++++++++++++- .../composables/chat/useChatPendingQueue.ts | 138 +++++- .../pendingInputWal.atomicHandoff.test.ts | 54 +++ .../src/utils/chat/pendingInputWal.ts | 93 ++++ 4 files changed, 711 insertions(+), 15 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 0472da325..21c95e1b5 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -191,6 +191,7 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { handoffs.set(ownerRequestId, handoff) const moved = [...records.values()] .filter(record => record.ownerRequestId === ownerRequestId) + .filter(record => record.state !== 'cancelling') .map(record => ({ ...record, sessionKey: acceptedSessionKey, @@ -241,6 +242,57 @@ function enableAtomicPendingMutations( }, ) wal.compareAndSwapPendingInput = compareAndSwap + wal.compareAndDeletePendingInput = vi.fn( + async (record, expectedWalRevision, equivalentSessionKeys = []) => { + const current = records.get(record.pendingInputId) + const sessionKeys = new Set([record.sessionKey, ...equivalentSessionKeys]) + if ( + !current + || !sessionKeys.has(current.sessionKey) + || current.clientRequestId !== record.clientRequestId + || current.clientMessageId !== record.clientMessageId + || (current.walRevision ?? 1) !== expectedWalRevision + ) return { + applied: false, + record: current ? structuredClone(current) : null, + } + records.delete(record.pendingInputId) + return { applied: true, record: null } + }, + ) + const compareAndSwapMany: NonNullable = vi.fn( + async ( + nextRecords: PendingInputWalRecord[], + expectedWalRevisions: Record, + equivalentSessionKeys: string[] = [], + ) => { + const targetSessionKey = nextRecords[0]?.sessionKey || '' + const sessionKeys = new Set([targetSessionKey, ...equivalentSessionKeys]) + const current = [...records.values()].filter(record => sessionKeys.has(record.sessionKey)) + const currentById = new Map(current.map(record => [record.pendingInputId, record])) + const valid = Boolean(targetSessionKey) + && nextRecords.length === current.length + && new Set(nextRecords.map(record => record.pendingInputId)).size === nextRecords.length + && nextRecords.every(record => { + const live = currentById.get(record.pendingInputId) + return live + && live.clientRequestId === record.clientRequestId + && live.clientMessageId === record.clientMessageId + && (live.walRevision ?? 1) === expectedWalRevisions[record.pendingInputId] + }) + if (!valid) { + return { applied: false, records: current.map(record => structuredClone(record)) } + } + const updated = nextRecords.map(record => ({ + ...structuredClone(record), + walRevision: expectedWalRevisions[record.pendingInputId]! + 1, + updatedAt: Date.now(), + })) + for (const record of updated) records.set(record.pendingInputId, record) + return { applied: true, records: updated.map(record => structuredClone(record)) } + }, + ) + wal.compareAndSwapPendingInputs = compareAndSwapMany wal.beginCancellation = vi.fn(async (record, expectedWalRevision, equivalentSessionKeys) => ( compareAndSwap( { ...record, state: 'cancelling' }, @@ -2646,8 +2698,9 @@ describe('useChatPendingQueue delivery state', () => { }) expect(records.get(pendingInputId)).toMatchObject({ state: 'cancelling', - walRevision: 2, }) + expect(records.get(pendingInputId)!.walRevision).toBeGreaterThanOrEqual(2) + await vi.waitFor(() => expect(pendingInputQueue.cancel).toHaveBeenCalled()) expect(wal.compareAndSwapPendingInput).toHaveBeenCalledWith( expect.objectContaining({ state: 'staged' }), 1, @@ -2658,6 +2711,183 @@ describe('useChatPendingQueue delivery state', () => { } }) + it('does not let a stale offline hydrate recreate a peer cancellation tombstone', async () => { + const pendingInputId = 'pending-peer-cancel-during-offline-hydrate' + const initialRecord: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId, + sessionKey: 'agent:main:webchat:test', + clientRequestId: 'request-peer-cancel-during-offline-hydrate', + clientMessageId: 'message-peer-cancel-during-offline-hydrate', + text: 'offline cancellation must win', + attachments: [], + intent: null, + state: 'retryable', + mayHaveServerCopy: true, + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const { wal, records } = memoryWal([initialRecord]) + enableAtomicPendingMutations(wal, records) + const listWal = wal.list + let markListStarted!: () => void + let releaseList!: () => void + const listStarted = new Promise(resolve => { markListStarted = resolve }) + const listGate = new Promise(resolve => { releaseList = resolve }) + wal.list = vi.fn(async sessionKey => { + const snapshot = await listWal(sessionKey) + markListStarted() + await listGate + return snapshot + }) + const { queue } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await listStarted + records.set(pendingInputId, { + ...initialRecord, + state: 'cancelling', + retainAfterCancel: undefined, + walRevision: 2, + updatedAt: 2, + }) + releaseList() + + await vi.waitFor(() => { + expect(queue.pendingQueue.value[0]?.pendingPersistenceState).toBe('cancelling') + }) + expect(records.get(pendingInputId)).toMatchObject({ + state: 'cancelling', + walRevision: 2, + }) + } finally { + queue.cleanup() + } + }) + + it('does not let a stale server omission delete a newer retained draft', async () => { + const pendingInputId = 'pending-retained-during-server-list' + const initialRecord: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId, + sessionKey: 'agent:main:webchat:test', + clientRequestId: 'request-retained-during-server-list', + clientMessageId: 'message-retained-during-server-list', + text: 'retain while the list is stale', + attachments: [], + intent: null, + state: 'staged', + mayHaveServerCopy: true, + requestFingerprint: 'sha256:retained-during-server-list', + serverRevision: 1, + walRevision: 1, + createdAt: 1, + updatedAt: 1, + } + const { wal, records } = memoryWal([initialRecord]) + enableAtomicPendingMutations(wal, records) + let markListStarted!: () => void + let releaseList!: () => void + const listStarted = new Promise(resolve => { markListStarted = resolve }) + const listGate = new Promise(resolve => { releaseList = resolve }) + const pendingInputQueue: PendingInputQueuePort = { + supportsQueue: () => true, + supportsReorder: () => false, + enqueue: vi.fn(async () => ({})), + list: vi.fn(async () => { + markListStarted() + await listGate + return [] + }), + cancel: vi.fn(async () => {}), + reorder: vi.fn(async () => ({ items: [] })), + } + const { queue } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + pendingInputQueue, + }) + try { + await listStarted + records.set(pendingInputId, { + ...initialRecord, + state: 'local_only', + retainAfterCancel: true, + mayHaveServerCopy: false, + requestFingerprint: undefined, + serverRevision: undefined, + walRevision: 2, + updatedAt: 2, + }) + releaseList() + + await vi.waitFor(() => { + expect(queue.pendingQueue.value[0]).toMatchObject({ + pendingPersistenceState: 'local_only', + pendingRetainAfterCancel: true, + pendingWalRevision: 2, + }) + }) + expect(records.get(pendingInputId)).toMatchObject({ + state: 'local_only', + retainAfterCancel: true, + walRevision: 2, + }) + } finally { + queue.cleanup() + } + }) + + it('adopts a newer peer local reorder during hydrate', async () => { + const sessionKey = 'agent:main:webchat:test' + const initial = ['A', 'B'].map((text, position): PendingInputWalRecord => ({ + schemaVersion: 1, + pendingInputId: `pending-peer-order-${text}`, + sessionKey, + clientRequestId: `request-peer-order-${text}`, + clientMessageId: `message-peer-order-${text}`, + text, + attachments: [], + intent: null, + state: 'local_only', + mayHaveServerCopy: false, + position, + walRevision: 1, + createdAt: position + 1, + updatedAt: position + 1, + })) + const { wal, records } = memoryWal(initial) + const { queue } = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + await vi.waitFor(() => { + expect(queue.pendingQueue.value.map(item => item.text)).toEqual(['A', 'B']) + }) + records.set(initial[0]!.pendingInputId, { + ...initial[0]!, + position: 1, + walRevision: 2, + updatedAt: 3, + }) + records.set(initial[1]!.pendingInputId, { + ...initial[1]!, + position: 0, + walRevision: 2, + updatedAt: 3, + }) + + await queue.hydratePendingQueue(sessionKey) + expect(queue.pendingQueue.value.map(item => item.text)).toEqual(['B', 'A']) + expect(queue.pendingQueue.value.map(item => item.pendingWalRevision)).toEqual([2, 2]) + } finally { + queue.cleanup() + } + }) + it('retains a reclassified draft when the cancel acknowledgement is lost', async () => { const { wal, records } = memoryWal([{ schemaVersion: 1, @@ -3780,6 +4010,215 @@ describe('useChatPendingQueue delivery state', () => { source.queue.cleanup() }) + it('keeps a trash tombstone when cancellation commits before handoff acceptance', async () => { + const sourceSessionKey = 'agent:main:webchat:test' + const targetSessionKey = 'agent:main:webchat:handoff-cancel-first' + const ownerRequestId = 'owner-handoff-cancel-first' + const { handoffs, records, wal } = memoryWal() + enableAtomicPendingMutations(wal, records) + handoffs.set(ownerRequestId, { + schemaVersion: 1, + ownerRequestId, + requestSessionKey: sourceSessionKey, + clientRequestId: ownerRequestId, + clientMessageId: 'message-handoff-cancel-first', + params: { + sessionKey: sourceSessionKey, + message: 'cancel before accepting the handoff', + clientRequestId: ownerRequestId, + clientMessageId: 'message-handoff-cancel-first', + }, + composerText: 'cancel before accepting the handoff', + recoveryAttachments: [], + state: 'submitting', + createdAt: 1, + updatedAt: 1, + }) + const beginCancellation = wal.beginCancellation! + let markCancellationCommitted!: () => void + let releaseCancellation!: () => void + const cancellationCommitted = new Promise(resolve => { + markCancellationCommitted = resolve + }) + const cancellationGate = new Promise(resolve => { releaseCancellation = resolve }) + wal.beginCancellation = vi.fn(async (record, expectedWalRevision, equivalentSessionKeys) => { + const mutation = await beginCancellation( + record, + expectedWalRevision, + equivalentSessionKeys, + ) + markCancellationCommitted() + await cancellationGate + return mutation + }) + const source = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + source.inputText.value = 'cancel before accepting the handoff' + await source.queue.enqueuePendingInput(source.inputText.value, { ownerRequestId }) + const pendingInputId = source.queue.pendingQueue.value[0]!.pendingInputId! + + expect(source.queue.removePendingChip(pendingUiId(source.queue, 0))).toBe(true) + await cancellationCommitted + await source.queue.recoverPendingQueueHandoff( + sourceSessionKey, + targetSessionKey, + ownerRequestId, + ) + expect(records.get(pendingInputId)).toMatchObject({ + sessionKey: sourceSessionKey, + ownerRequestId, + state: 'cancelling', + }) + + releaseCancellation() + await vi.waitFor(() => expect([...records.values()]).toEqual([])) + source.queue.switchPendingQueue(targetSessionKey) + source.sessionKey.value = targetSessionKey + await source.queue.hydratePendingQueue(targetSessionKey) + expect(source.queue.pendingQueue.value).toEqual([]) + } finally { + source.queue.cleanup() + } + }) + + it('lets destructive trash follow an identity migrated by handoff acceptance', async () => { + const sourceSessionKey = 'agent:main:webchat:test' + const targetSessionKey = 'agent:main:webchat:handoff-accept-first' + const ownerRequestId = 'owner-handoff-accept-first' + const { handoffs, records, wal } = memoryWal() + enableAtomicPendingMutations(wal, records) + handoffs.set(ownerRequestId, { + schemaVersion: 1, + ownerRequestId, + requestSessionKey: sourceSessionKey, + clientRequestId: ownerRequestId, + clientMessageId: 'message-handoff-accept-first', + params: { + sessionKey: sourceSessionKey, + message: 'accept while destructive trash is waiting', + clientRequestId: ownerRequestId, + clientMessageId: 'message-handoff-accept-first', + }, + composerText: 'accept while destructive trash is waiting', + recoveryAttachments: [], + state: 'submitting', + createdAt: 1, + updatedAt: 1, + }) + const beginCancellation = wal.beginCancellation! + let markCancellationStarted!: () => void + let releaseCancellation!: () => void + const cancellationStarted = new Promise(resolve => { markCancellationStarted = resolve }) + const cancellationGate = new Promise(resolve => { releaseCancellation = resolve }) + let firstAttempt = true + let firstMutation: Awaited>> + wal.beginCancellation = vi.fn(async (record, expectedWalRevision, equivalentSessionKeys) => { + if (firstAttempt) { + firstAttempt = false + markCancellationStarted() + await cancellationGate + } + const mutation = await beginCancellation( + record, + expectedWalRevision, + equivalentSessionKeys, + ) + firstMutation ??= mutation + return mutation + }) + const source = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + source.inputText.value = 'accept while destructive trash is waiting' + await source.queue.enqueuePendingInput(source.inputText.value, { ownerRequestId }) + const queuedItem = source.queue.pendingQueue.value[0]! + const pendingInputId = queuedItem.pendingInputId! + + expect(source.queue.removePendingChip(pendingUiId(source.queue, 0))).toBe(true) + await cancellationStarted + await source.queue.recoverPendingQueueHandoff( + sourceSessionKey, + targetSessionKey, + ownerRequestId, + ) + expect(records.get(pendingInputId)).toMatchObject({ + sessionKey: targetSessionKey, + ownerRequestId: undefined, + state: 'saving', + }) + + releaseCancellation() + await vi.waitFor(() => expect(firstMutation).toBeDefined()) + expect(firstMutation!.record).toMatchObject({ + sessionKey: targetSessionKey, + clientRequestId: queuedItem.pendingClientRequestId, + clientMessageId: queuedItem.pendingClientMessageId, + }) + await vi.waitFor(() => expect(records.get(pendingInputId)?.state).not.toBe('saving')) + expect(wal.beginCancellation).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect([...records.values()]).toEqual([])) + source.queue.switchPendingQueue(targetSessionKey) + source.sessionKey.value = targetSessionKey + await source.queue.hydratePendingQueue(targetSessionKey) + expect(source.queue.pendingQueue.value).toEqual([]) + expect(wal.beginCancellation).toHaveBeenCalledTimes(2) + } finally { + source.queue.cleanup() + } + }) + + it('does not let failed-handoff reclassification overwrite peer cancellation', async () => { + const ownerRequestId = 'owner-failed-handoff-cancellation' + const { records, wal } = memoryWal() + const compareAndSwap = enableAtomicPendingMutations(wal, records) + const source = makeQueue(undefined, () => false, undefined, undefined, { + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + source.inputText.value = 'keep cancellation over failed handoff' + await source.queue.enqueuePendingInput(source.inputText.value, { ownerRequestId }) + const item = source.queue.pendingQueue.value[0]! + const pendingInputId = item.pendingInputId! + let markReclassificationStarted!: () => void + let releaseReclassification!: () => void + const reclassificationStarted = new Promise(resolve => { + markReclassificationStarted = resolve + }) + const reclassificationGate = new Promise(resolve => { + releaseReclassification = resolve + }) + vi.mocked(compareAndSwap).mockImplementationOnce(async () => { + markReclassificationStarted() + await reclassificationGate + const current = records.get(pendingInputId) + return { applied: false, record: current ? structuredClone(current) : null } + }) + + const failed = source.queue.failPendingQueueHandoff(ownerRequestId) + await reclassificationStarted + records.set(pendingInputId, { + ...records.get(pendingInputId)!, + state: 'cancelling', + retainAfterCancel: undefined, + walRevision: (records.get(pendingInputId)!.walRevision ?? 1) + 1, + updatedAt: Date.now(), + }) + releaseReclassification() + await failed + + expect(records.get(pendingInputId)).toMatchObject({ state: 'cancelling' }) + expect(item.pendingPersistenceState).toBe('cancelling') + } finally { + source.queue.cleanup() + } + }) + it('commits a staged reorder through the batch RPC before releasing drain', async () => { const sessionKey = 'agent:main:webchat:test' const initial = ['A', 'B', 'C'].map((text, position): PendingInputWalRecord => ({ diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 7c9d786b1..b8019689f 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -388,7 +388,14 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { sessionKey: string, ): boolean { return queueSessionKey(record.sessionKey) === queueSessionKey(sessionKey) - && record.clientRequestId === item.pendingClientRequestId + && walRecordMatchesItemIdentity(record, item) + } + + function walRecordMatchesItemIdentity( + record: PendingInputWalRecord, + item: ChatPendingItem, + ): boolean { + return record.clientRequestId === item.pendingClientRequestId && record.clientMessageId === item.pendingClientMessageId } @@ -524,6 +531,41 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { mutation.record.state === 'cancelling' && mutation.record.retainAfterCancel !== true ) rememberRemoval(sessionKey, pendingInputId) + if (mutation.record.state === 'cancelling' && supportsServerQueue()) { + void retryCancellingItem(item) + } + } + return false + } + + async function deleteStagedWalItem( + item: ChatPendingItem, + sessionKey: string, + ): Promise { + const pendingInputId = item.pendingInputId + const wal = options.pendingInputWal + if (!pendingInputId || !wal) return false + if (!wal.compareAndDeletePendingInput) { + await wal.delete(pendingInputId) + return true + } + const expectedWalRevision = item.pendingWalRevision ?? 1 + const mutation = await wal.compareAndDeletePendingInput( + walRecordForItem(item, item.pendingPersistenceState || 'staged'), + expectedWalRevision, + walLookupSessionKeys(sessionKey), + ) + if (mutation.applied) return true + if (!mutation.record) { + rememberRemoval(sessionKey, pendingInputId) + removePendingIdentity(sessionKey, pendingInputId) + return false + } + if (walRecordOwnsItem(mutation.record, item, sessionKey)) { + replaceItemFromWalRecord(item, mutation.record) + if (mutation.record.state === 'cancelling' && supportsServerQueue()) { + void retryCancellingItem(item) + } } return false } @@ -755,10 +797,15 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ) for (const record of records) { const existing = existingById.get(record.pendingInputId) - if ( - existing - && (record.retainAfterCancel === true || record.state === 'cancelling') - ) { + const newerWalRevision = (record.walRevision ?? 1) > (existing?.pendingWalRevision ?? 0) + const localOperationOwnsItem = stagingOperations.has(record.pendingInputId) + || cancellationOperations.has(record.pendingInputId) + || locallyCreatingIds.has(record.pendingInputId) + if (existing && ( + record.retainAfterCancel === true + || record.state === 'cancelling' + || (newerWalRevision && !localOperationOwnsItem) + )) { replaceItemFromWalRecord(existing, record) continue } @@ -838,7 +885,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { || item.pendingRetainAfterCancel === true ) continue if (item.pendingPersistenceState !== 'local_only') { - void writeWalItem(item, 'local_only') + void writeStagingWalItem(item, 'local_only', ownerSessionKey) } } return @@ -961,9 +1008,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (item.pendingPersistenceState === 'staged') { // Another tab either cancelled or dispatched the server row. Both // outcomes are terminal for this WAL entry. - await wal.delete(item.pendingInputId!) - const index = pendingQueue.value.indexOf(item) - if (index >= 0) pendingQueue.value.splice(index, 1) + if (await deleteStagedWalItem(item, ownerSessionKey)) { + const index = pendingQueue.value.indexOf(item) + if (index >= 0) pendingQueue.value.splice(index, 1) + } continue } // This also upgrades IndexedDB-only rows created against an older @@ -1293,7 +1341,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ): Promise { if (!durableItem(item)) return true const previousState = item.pendingPersistenceState || 'saving' - const sessionKey = queueSessionKey(item.ownerSessionKey) + let sessionKey = queueSessionKey(item.ownerSessionKey) const retainAfterCancel = cancelOptions.retainAfterCancel === true const expectedInvalidation = cancellationInvalidations.get(item.pendingInputId!) ?? 0 if ( @@ -1342,6 +1390,9 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { break } const sameIdentity = walRecordOwnsItem(mutation.record, item, sessionKey) + const migratedIdentity = !retainAfterCancel + && !sameIdentity + && walRecordMatchesItemIdentity(mutation.record, item) if ( mutation.record.state !== 'cancelling' || mutation.record.retainAfterCancel === true @@ -1362,6 +1413,12 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { rememberRemoval(sessionKey, item.pendingInputId!) continue } + if (migratedIdentity) { + replaceItemFromWalRecord(item, mutation.record) + sessionKey = queueSessionKey(mutation.record.sessionKey) + rememberRemoval(sessionKey, item.pendingInputId!) + continue + } transition = 'conflict' break } @@ -1508,8 +1565,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (durableItem(item)) { void cancelDurableItem(item).then(cancelled => { if (!cancelled) return - const currentIndex = pendingQueue.value.indexOf(item) - if (currentIndex >= 0) pendingQueue.value.splice(currentIndex, 1) + removePendingIdentity( + queueSessionKey(item.ownerSessionKey), + item.pendingInputId!, + ) }) return true } @@ -1751,7 +1810,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ...pendingQueue.value, ...[...parkedQueues.values()].flat(), ].filter(item => item.ownerRequestId === ownerRequestId && durableItem(item)) - await Promise.all(owned.map(item => writeWalItem(item, 'retryable').catch(() => {}))) + await Promise.all(owned.map(item => ( + writeStagingWalItem(item, 'retryable', queueSessionKey(item.ownerSessionKey)) + .catch(() => false) + ))) for (const item of owned) { broadcastChange(queueSessionKey(item.ownerSessionKey), item.pendingInputId) } @@ -2278,6 +2340,48 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { orderedIds.length !== pendingQueue.value.length || orderedIds.some(id => !id || !byId.has(id)) ) throw new Error('Gateway returned an incomplete pending order') + const orderedItems = orderedIds.map(id => ( + pendingQueue.value.find(item => item.pendingInputId === id)! + )) + const expectedWalRevisions = Object.fromEntries(orderedItems.map(item => [ + item.pendingInputId!, + item.pendingWalRevision ?? 1, + ])) + const nextRecords = orderedItems.map(item => { + const serverItem = byId.get(item.pendingInputId || '')! + return walRecordForItem({ + ...item, + pendingPosition: Number(serverItem.position), + pendingServerRevision: Number(serverItem.revision), + pendingWalRevision: (item.pendingWalRevision ?? 1) + 1, + }, 'staged') + }) + if (options.pendingInputWal?.compareAndSwapPendingInputs) { + const result = await options.pendingInputWal.compareAndSwapPendingInputs( + nextRecords, + expectedWalRevisions, + walLookupSessionKeys(options.sessionKey.value), + ) + if (!result.applied) { + mergeWalRecords(result.records.map(record => ({ + ...record, + sessionKey: queueSessionKey(record.sessionKey), + })), queueSessionKey()) + for (const item of pendingQueue.value) { + if (item.pendingPersistenceState === 'cancelling') void retryCancellingItem(item) + } + throw Object.assign( + new Error('Pending queue changed before server reorder persistence'), + { code: 'PENDING_WAL_CONFLICT' }, + ) + } + restorePendingOrder(orderedIds) + const committedById = new Map(result.records.map(record => [record.pendingInputId, record])) + for (const item of pendingQueue.value) { + replaceItemFromWalRecord(item, committedById.get(item.pendingInputId!)!) + } + return + } restorePendingOrder(orderedIds) for (const item of pendingQueue.value) { const serverItem = byId.get(item.pendingInputId || '')! @@ -2395,7 +2499,13 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { await applyServerReorderItems(Array.isArray(response.items) ? response.items : []) broadcastChange(queueSessionKey()) finishPendingReorder() - } catch { + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'PENDING_WAL_CONFLICT') { + finishPendingReorder() + await hydratePendingQueue(queueSessionKey()) + options.onPendingPersistenceError?.('order_conflict') + return + } // An unknown RPC result may have committed. Keep the delivery barrier // until an authoritative list proves which order won. if (await recoverServerReorder()) finishPendingReorder() diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index 312f3b20c..8c9f2a038 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -574,4 +574,58 @@ describe('BrowserPendingInputWal atomic mutations', () => { wal!.close() }) + + it('accepts a handoff without migrating its cancellation tombstone', async () => { + const factory = new ControlledIdbFactory() + const wal = createPendingInputWal(factory.idbFactory) + expect(wal).not.toBeNull() + const ownerRequestId = 'owner-cancelling-handoff' + const sourceSessionKey = 'agent:main:webchat:cancel-source' + const targetSessionKey = 'agent:main:webchat:cancel-target' + const pending: PendingInputWalRecord = { + schemaVersion: 1, + pendingInputId: 'pending-cancelling-handoff', + sessionKey: sourceSessionKey, + clientRequestId: 'request-cancelling-handoff', + clientMessageId: 'message-cancelling-handoff', + text: 'do not migrate the tombstone', + attachments: [], + intent: null, + ownerRequestId, + state: 'cancelling', + mayHaveServerCopy: true, + walRevision: 2, + createdAt: 1, + updatedAt: 2, + } + await wal!.put(pending) + await wal!.putHandoff!({ + schemaVersion: 1, + ownerRequestId, + requestSessionKey: sourceSessionKey, + clientRequestId: ownerRequestId, + clientMessageId: pending.clientMessageId, + params: { + sessionKey: sourceSessionKey, + message: pending.text, + clientRequestId: ownerRequestId, + clientMessageId: pending.clientMessageId, + }, + composerText: pending.text, + recoveryAttachments: [], + state: 'submitting', + createdAt: 1, + updatedAt: 1, + }) + + const committed = await wal!.acceptHandoff!(ownerRequestId, targetSessionKey) + + expect(committed?.records).toEqual([]) + expect(committed?.handoff).toMatchObject({ + state: 'accepted', + acceptedSessionKey: targetSessionKey, + }) + expect(factory.record(PENDING_STORE, pending.pendingInputId)).toEqual(pending) + wal!.close() + }) }) diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.ts index 2aad5b09a..56de61f4d 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -75,6 +75,12 @@ export interface PendingInputWalMutation { record: PendingInputWalRecord | null } +export interface PendingInputWalBatchMutation { + applied: boolean + /** The committed rows, or the current live session rows after a conflict. */ + records: PendingInputWalRecord[] +} + export interface AcceptedHandoffCommit { handoff: ResponseHandoffWalRecord records: PendingInputWalRecord[] @@ -108,6 +114,18 @@ export interface PendingInputWal { expectedWalRevision: number, equivalentSessionKeys?: string[], ) => Promise + /** Delete one live row only while its exact identity and WAL revision still match. */ + compareAndDeletePendingInput?: ( + record: PendingInputWalRecord, + expectedWalRevision: number, + equivalentSessionKeys?: string[], + ) => Promise + /** Atomically replace a complete session queue while every exact identity/revision still matches. */ + compareAndSwapPendingInputs?: ( + records: PendingInputWalRecord[], + expectedWalRevisions: Record, + equivalentSessionKeys?: string[], + ) => Promise commitOrder?: ( sessionKey: string, orderedIds: string[], @@ -449,6 +467,77 @@ class BrowserPendingInputWal implements PendingInputWal { return { applied: true, record: updated } } + async compareAndDeletePendingInput( + record: PendingInputWalRecord, + expectedWalRevision: number, + equivalentSessionKeys: string[] = [], + ): Promise { + const database = await this.database() + const transaction = database.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const raw = await requestResult(store.get(record.pendingInputId)) + if ( + !isPendingInputWalRecord(raw) + || !new Set([record.sessionKey, ...equivalentSessionKeys]).has(raw.sessionKey) + || raw.clientRequestId !== record.clientRequestId + || raw.clientMessageId !== record.clientMessageId + || (raw.walRevision ?? 1) !== expectedWalRevision + ) { + await transactionDone(transaction) + return { + applied: false, + record: isPendingInputWalRecord(raw) ? cloneRecord(raw) : null, + } + } + store.delete(record.pendingInputId) + await transactionDone(transaction) + return { applied: true, record: null } + } + + async compareAndSwapPendingInputs( + records: PendingInputWalRecord[], + expectedWalRevisions: Record, + equivalentSessionKeys: string[] = [], + ): Promise { + const database = await this.database() + const transaction = database.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const raw = await requestResult(store.getAll()) + const targetSessionKey = records[0]?.sessionKey || '' + const sessionKeys = new Set([targetSessionKey, ...equivalentSessionKeys]) + const current = (raw as unknown[]) + .filter(isPendingInputWalRecord) + .filter(record => sessionKeys.has(record.sessionKey)) + const currentById = new Map(current.map(record => [record.pendingInputId, record])) + const valid = Boolean(targetSessionKey) + && records.length === current.length + && new Set(records.map(record => record.pendingInputId)).size === records.length + && records.every(record => { + const live = currentById.get(record.pendingInputId) + return live + && live.clientRequestId === record.clientRequestId + && live.clientMessageId === record.clientMessageId + && (live.walRevision ?? 1) === expectedWalRevisions[record.pendingInputId] + }) + if (!valid) { + await transactionDone(transaction) + return { applied: false, records: current.map(cloneRecord) } + } + const updated = records.map(record => { + const expectedWalRevision = expectedWalRevisions[record.pendingInputId]! + const next = cloneRecord({ + ...record, + sessionKey: targetSessionKey, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + }) + store.put(next) + return next + }) + await transactionDone(transaction) + return { applied: true, records: updated } + } + async list(sessionKey: string): Promise { const database = await this.database() const transaction = database.transaction(STORE_NAME, 'readonly') @@ -642,6 +731,10 @@ class BrowserPendingInputWal implements PendingInputWal { const records = (rawPending as unknown[]) .filter(isPendingInputWalRecord) .filter(record => record.ownerRequestId === ownerRequestId) + // A durable delete intent keeps its current owner. Migrating it could + // either resurrect the row as saving or move it out from under the + // cancellation operation that already acquired this transaction. + .filter(record => record.state !== 'cancelling') .map(record => { const next = cloneRecord({ ...record, From caf83d4df166e1b9337e255dc8d5dadf32a0695a Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 22:50:44 +0800 Subject: [PATCH 16/18] Fence composer recovery during handoff --- .../chat/useChatPendingQueue.test.ts | 102 +++++++++++++ .../composables/chat/useChatPendingQueue.ts | 26 ++++ .../chat/useChatSend.attachments.test.ts | 136 ++++++++++++++++++ .../src/composables/chat/useChatSend.ts | 31 +++- 4 files changed, 293 insertions(+), 2 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 21c95e1b5..58a7d3fef 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { useChatPendingQueue, + type PendingQueueOwnerContext, type UseChatPendingQueueOptions, } from './useChatPendingQueue' import { createLegacyPendingInputQueue } from '@/adapters/gateway/pendingInputQueueV4' @@ -2253,6 +2254,107 @@ describe('useChatPendingQueue delivery state', () => { }, ) + it('blocks composer recovery and invalidates an existing restore during delayed handoff acceptance', async () => { + const sourceSessionKey = 'agent:main:webchat:test' + const targetSessionKey = 'agent:main:webchat:delayed-handoff-target' + const ownerRequestId = 'owner-delayed-handoff-recovery' + const ownerContext = ref(null) + const { handoffs, records, wal } = memoryWal() + enableAtomicPendingMutations(wal, records) + handoffs.set(ownerRequestId, { + schemaVersion: 1, + ownerRequestId, + requestSessionKey: sourceSessionKey, + clientRequestId: ownerRequestId, + clientMessageId: 'message-delayed-handoff-recovery', + params: { + sessionKey: sourceSessionKey, + message: 'handoff this queue', + clientRequestId: ownerRequestId, + clientMessageId: 'message-delayed-handoff-recovery', + }, + composerText: 'handoff this queue', + recoveryAttachments: [], + state: 'submitting', + createdAt: 1, + updatedAt: 1, + }) + const retainCancelled = wal.retainCancelled! + let markRetainStarted!: () => void + let releaseRetain!: () => void + const retainStarted = new Promise(resolve => { markRetainStarted = resolve }) + const retainGate = new Promise(resolve => { releaseRetain = resolve }) + wal.retainCancelled = vi.fn(async (record, expectedWalRevision, equivalentSessionKeys) => { + markRetainStarted() + await retainGate + return retainCancelled(record, expectedWalRevision, equivalentSessionKeys) + }) + const acceptHandoff = wal.acceptHandoff! + let markAcceptStarted!: () => void + let releaseAccept!: () => void + const acceptStarted = new Promise(resolve => { markAcceptStarted = resolve }) + const acceptGate = new Promise(resolve => { releaseAccept = resolve }) + wal.acceptHandoff = vi.fn(async ( + currentOwnerRequestId, + acceptedSessionKey, + shouldAccept, + handoffSignal, + ) => { + markAcceptStarted() + await acceptGate + return acceptHandoff( + currentOwnerRequestId, + acceptedSessionKey, + shouldAccept, + handoffSignal, + ) + }) + const initial = makeQueue(undefined, () => false, undefined, undefined, { + ownerContext, + pendingInputWal: wal, + hasRpcMethod: () => false, + }) + try { + initial.inputText.value = 'source text with attachment' + initial.pendingAttachments.value = [{ + kind: 'staged', + local_id: 501, + name: 'source.txt', + mime: 'text/plain', + file_uuid: 'source-upload', + }] + await initial.queue.enqueuePendingInput( + initial.inputText.value, + { ownerRequestId }, + ) + initial.inputText.value = 'second source item' + await initial.queue.enqueuePendingInput(initial.inputText.value, { ownerRequestId }) + const firstId = pendingUiId(initial.queue, 0) + + expect(initial.queue.editPendingItem(firstId)).toBe(true) + await retainStarted + ownerContext.value = { sessionKey: sourceSessionKey, ownerRequestId } + const adoption = initial.queue.adoptPendingQueue(targetSessionKey, ownerRequestId) + await acceptStarted + + expect(initial.queue.popPendingTail()).toBe(false) + expect(initial.queue.popAllPendingIntoComposer()).toBe(false) + releaseRetain() + await vi.waitFor(() => { + expect(initial.inputText.value).toBe('') + expect(initial.pendingAttachments.value).toEqual([]) + }) + + releaseAccept() + await adoption + initial.sessionKey.value = targetSessionKey + expect(initial.inputText.value).toBe('') + expect(initial.pendingAttachments.value).toEqual([]) + } finally { + initial.queue.cleanup() + } + }) + it('retains delayed composer recovery in the WAL after cleanup for the next hydrate', async () => { const { wal, records } = memoryWal() const writeWal = wal.put diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index b8019689f..13577c662 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -196,6 +196,28 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { () => { composerRevision += 1 }, { deep: true, flush: 'sync' }, ) + const ownerContextKey = (context: PendingQueueOwnerContext | null | undefined) => ( + context + ? `${queueSessionKey(context.sessionKey)}\u0000${context.ownerRequestId}` + : '' + ) + const responseHandoffOwnsCurrentQueue = () => { + const context = options.ownerContext?.value + return Boolean( + context + && queueSessionKey(context.sessionKey) === queueSessionKey(), + ) + } + const stopOwnerContextWatch = options.ownerContext + ? watch(options.ownerContext, (current, previous) => { + if (current && ownerContextKey(current) !== ownerContextKey(previous)) { + // Establishing response-handoff ownership is a synchronous recovery + // boundary. No edit/pop callback captured before it may later write + // the source payload into the handoff target's composer. + activeQueueLease += 1 + } + }, { flush: 'sync' }) + : () => {} let pendingDrainTimer: ReturnType | null = null let deferredDrainRequested = false const isReordering = ref(false) @@ -2013,6 +2035,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { || item.promptAnnotationIds?.length || item.pendingPersistenceState === 'saving' || item.pendingPersistenceState === 'cancelling' + || responseHandoffOwnsCurrentQueue() || hasUneditablePendingAttachments(item) ) return false const restore = () => { @@ -2045,6 +2068,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } function popPendingTail() { + if (responseHandoffOwnsCurrentQueue()) return false // Hidden controls and explicit/ambiguous steer deliveries must retain // their own transport identity instead of being converted into a fresh // composer send. @@ -2085,6 +2109,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } function popAllPendingIntoComposer(): boolean { + if (responseHandoffOwnsCurrentQueue()) return false cancelPendingReorder() clearPendingDrainAfterTerminalTimer() if (!options.hasComposer() || pendingQueue.value.length === 0) return false @@ -2545,6 +2570,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { activeQueueLease += 1 hydrateGeneration += 1 stopComposerRevisionWatch() + stopOwnerContextWatch() clearPendingDrainAfterTerminalTimer() parkedQueues.clear() broadcast?.close() diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 77bebdf2b..0e8bb7b58 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -271,6 +271,142 @@ function usageReplayMessages(): ChatMessage[] { ] } +describe('useChatSend response-handoff persistence boundary', () => { + function delayedHandoffWal() { + let retained: ResponseHandoffWalRecord | null = null + let markPersistStarted!: () => void + let releasePersist!: () => void + let firstWrite = true + const persistStarted = new Promise(resolve => { markPersistStarted = resolve }) + const persistGate = new Promise(resolve => { releasePersist = resolve }) + const wal: PendingInputWal = { + put: async () => {}, + list: async () => [], + delete: async () => {}, + putHandoff: vi.fn(async record => { + retained = structuredClone(record) + if (firstWrite) { + firstWrite = false + markPersistStarted() + await persistGate + } + }), + deleteHandoff: vi.fn(async () => { retained = null }), + acceptHandoff: async (ownerRequestId, acceptedSessionKey) => { + if (!retained || retained.ownerRequestId !== ownerRequestId) { + throw new Error('missing handoff') + } + const handoff: ResponseHandoffWalRecord = { + ...retained, + state: 'accepted', + acceptedSessionKey, + updatedAt: Date.now(), + } + retained = handoff + return { handoff, records: [] } + }, + close: () => {}, + } + return { + wal, + persistStarted, + releasePersist, + retained: () => retained, + } + } + + it('stops a delayed fork send after A-to-B navigation without touching B composer', async () => { + const parentSessionKey = 'agent:main:webchat:persist-parent' + const childSessionKey = 'agent:main:webchat:persist-other' + const sessionKey = ref(parentSessionKey) + const inputText = ref('parent draft') + const parentAttachment: Attachment = { + kind: 'staged', + local_id: 31, + name: 'parent.txt', + mime: 'text/plain', + file_uuid: 'parent-upload', + } + const childAttachment: Attachment = { + kind: 'staged', + local_id: 32, + name: 'child.txt', + mime: 'text/plain', + file_uuid: 'child-upload', + } + const pendingAttachments = ref([parentAttachment]) + const pendingForkBeforeMessageId = ref('parent-message') + const delayed = delayedHandoffWal() + const { api, options, rpc } = makeOptions({ + sessionKey, + inputText, + pendingAttachments, + pendingForkBeforeMessageId, + pendingInputWal: delayed.wal, + }) + + const sending = api.onSend() + await delayed.persistStarted + sessionKey.value = childSessionKey + inputText.value = 'child draft' + pendingAttachments.value = [childAttachment] + pendingForkBeforeMessageId.value = null + delayed.releasePersist() + await sending + + expect(rpc.call).not.toHaveBeenCalled() + expect(options.messages.value).toEqual([]) + expect(inputText.value).toBe('child draft') + expect(pendingAttachments.value).toEqual([childAttachment]) + expect(delayed.retained()).toBeNull() + }) + + it('sends only the persisted snapshot while preserving newer same-session content', async () => { + const sessionKey = ref('agent:main:webchat:persist-same-session') + const inputText = ref('persisted draft') + const persistedAttachment: Attachment = { + kind: 'staged', + local_id: 41, + name: 'persisted.txt', + mime: 'text/plain', + file_uuid: 'persisted-upload', + } + const newerAttachment: Attachment = { + kind: 'staged', + local_id: 42, + name: 'newer.txt', + mime: 'text/plain', + file_uuid: 'newer-upload', + } + const pendingAttachments = ref([persistedAttachment]) + const pendingForkBeforeMessageId = ref('parent-message') + const delayed = delayedHandoffWal() + const { api, rpc } = makeOptions({ + sessionKey, + inputText, + pendingAttachments, + pendingForkBeforeMessageId, + pendingInputWal: delayed.wal, + }) + rpc.call.mockResolvedValue({ sessionKey: sessionKey.value }) + + const sending = api.onSend() + await delayed.persistStarted + inputText.value = 'newer draft' + pendingAttachments.value = [persistedAttachment, newerAttachment] + delayed.releasePersist() + await sending + + expect(rpc.call).toHaveBeenCalledWith('chat.send', expect.objectContaining({ + sessionKey: sessionKey.value, + message: 'persisted draft', + attachments: [expect.objectContaining({ name: 'persisted.txt' })], + })) + expect(inputText.value).toBe('newer draft') + expect(pendingAttachments.value).toEqual([newerAttachment]) + }) +}) + describe('useChatSend dedicated usage-barrier replay', () => { it('atomically admits only one of two cross-tab clicks for the same barrier', async () => { const pendingInputWal = memoryHandoffWal() diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 6a618b26c..3e5706a09 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -2937,6 +2937,7 @@ export function useChatSend(options: UseChatSendOptions) { return true } let durableHandoffRecord: ResponseHandoffWalRecord | null = null + let consumeComposerSnapshotAfterHandoffPersistence = false const rejectBeforeDispatch = async (): Promise => { if (attempt && sendOpts.acceptedVisibleReplay) { sendOpts.rememberRetryableAttempt?.(attempt) @@ -2944,6 +2945,17 @@ export function useChatSend(options: UseChatSendOptions) { await discardUnsentResponseHandoff(durableHandoffRecord) return 'not_sent' } + const revalidateAfterHandoffPersistence = (): boolean => { + if (options.sessionKey.value !== requestSessionKey) return false + if ( + sendOpts.composerSnapshot + && !composerMatchesSnapshot(sendOpts.composerSnapshot) + ) { + preserveComposer = true + consumeComposerSnapshotAfterHandoffPersistence = true + } + return preDispatchAllowed() + } if (!attempt) { const durablePendingItem = sendOpts.durablePendingItem const clientMessageId = durablePendingItem?.pendingClientMessageId @@ -3013,7 +3025,7 @@ export function useChatSend(options: UseChatSendOptions) { if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { return rejectBeforeDispatch() } - if (!preDispatchAllowed()) return rejectBeforeDispatch() + if (!revalidateAfterHandoffPersistence()) return rejectBeforeDispatch() } if (!sendOpts.acceptedVisibleReplay) { const now = new Date().toISOString() @@ -3040,7 +3052,7 @@ export function useChatSend(options: UseChatSendOptions) { if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { return rejectBeforeDispatch() } - if (!preDispatchAllowed()) return rejectBeforeDispatch() + if (!revalidateAfterHandoffPersistence()) return rejectBeforeDispatch() } if (!preDispatchAllowed()) return rejectBeforeDispatch() if (!preserveComposer) options.closeSlashMenu() @@ -3067,6 +3079,21 @@ export function useChatSend(options: UseChatSendOptions) { options.pendingAttachments.value = options.pendingAttachments.value.filter( attachment => !originalAttachmentRefs.has(attachment), ) + if (consumeComposerSnapshotAfterHandoffPersistence) { + if (options.inputText.value === sendOpts.composerSnapshot.inputText) { + options.inputText.value = '' + } + if (options.pendingSessionIntent.value === sendOpts.composerSnapshot.intent) { + options.pendingSessionIntent.value = null + } + if ( + options.pendingForkBeforeMessageId.value + === sendOpts.composerSnapshot.forkBeforeMessageId + ) { + options.pendingForkBeforeMessageId.value = null + } + options.autoResizeTextarea() + } } // A steer send rides an already-active stream; restarting it would wipe // the partial output of the run being steered. From 5d1078e0d6b852a57fd0fa83fb1dc7ac18aeb14d Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 22:59:23 +0800 Subject: [PATCH 17/18] Keep source queue fenced during handoff --- .../composables/chat/useChatPendingQueue.test.ts | 9 +++++++-- .../src/composables/chat/useChatPendingQueue.ts | 13 +++++++++++-- .../src/composables/chat/useChatSend.ts | 7 ++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 58a7d3fef..bf804c47b 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts @@ -2254,7 +2254,7 @@ describe('useChatPendingQueue delivery state', () => { }, ) - it('blocks composer recovery and invalidates an existing restore during delayed handoff acceptance', async () => { + it('keeps the source recovery fence after context targets B but session remains A', async () => { const sourceSessionKey = 'agent:main:webchat:test' const targetSessionKey = 'agent:main:webchat:delayed-handoff-target' const ownerRequestId = 'owner-delayed-handoff-recovery' @@ -2333,10 +2333,15 @@ describe('useChatPendingQueue delivery state', () => { expect(initial.queue.editPendingItem(firstId)).toBe(true) await retainStarted - ownerContext.value = { sessionKey: sourceSessionKey, ownerRequestId } + ownerContext.value = { + sessionKey: targetSessionKey, + sourceSessionKey, + ownerRequestId, + } const adoption = initial.queue.adoptPendingQueue(targetSessionKey, ownerRequestId) await acceptStarted + expect(initial.sessionKey.value).toBe(sourceSessionKey) expect(initial.queue.popPendingTail()).toBe(false) expect(initial.queue.popAllPendingIntoComposer()).toBe(false) releaseRetain() diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts index 13577c662..08685860c 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -107,6 +107,8 @@ export interface PendingCancelOptions { export interface PendingQueueOwnerContext { sessionKey: string + /** Original visible queue fenced until response-session adoption settles. */ + sourceSessionKey?: string ownerRequestId: string } @@ -198,14 +200,21 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ) const ownerContextKey = (context: PendingQueueOwnerContext | null | undefined) => ( context - ? `${queueSessionKey(context.sessionKey)}\u0000${context.ownerRequestId}` + ? [ + queueSessionKey(context.sessionKey), + queueSessionKey(context.sourceSessionKey), + context.ownerRequestId, + ].join('\u0000') : '' ) const responseHandoffOwnsCurrentQueue = () => { const context = options.ownerContext?.value return Boolean( context - && queueSessionKey(context.sessionKey) === queueSessionKey(), + && ( + queueSessionKey(context.sessionKey) === queueSessionKey() + || queueSessionKey(context.sourceSessionKey) === queueSessionKey() + ), ) } const stopOwnerContextWatch = options.ownerContext diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index 3e5706a09..df80f34a1 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -1268,7 +1268,11 @@ export function useChatSend(options: UseChatSendOptions) { } activeResponseHandoff = gate if (durableRecord) { - options.pendingQueueOwnerContext.value = { sessionKey: requestSessionKey, ownerRequestId } + options.pendingQueueOwnerContext.value = { + sessionKey: requestSessionKey, + sourceSessionKey: requestSessionKey, + ownerRequestId, + } } return gate } @@ -1567,6 +1571,7 @@ export function useChatSend(options: UseChatSendOptions) { if (activeResponseHandoff === gate) { options.pendingQueueOwnerContext.value = { sessionKey: key, + sourceSessionKey: gate.requestSessionKey, ownerRequestId: gate.ownerRequestId, } } From c9b33d3be835f4b5ffda03fe432444e50b3f479f Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Wed, 2 Sep 2026 23:15:29 +0800 Subject: [PATCH 18/18] Fence source queue before handoff persistence --- .../chat/useChatSend.attachments.test.ts | 103 +++++++++++++++ .../src/composables/chat/useChatSend.ts | 125 ++++++++++-------- 2 files changed, 174 insertions(+), 54 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts index 0e8bb7b58..9c4072e0f 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.attachments.test.ts @@ -39,6 +39,7 @@ import { import { RpcTransportError } from '@/lib/rpc' import type { PendingInputWal, + PendingInputWalRecord, ResponseHandoffWalRecord, } from '@/utils/chat/pendingInputWal' @@ -335,18 +336,54 @@ describe('useChatSend response-handoff persistence boundary', () => { file_uuid: 'child-upload', } const pendingAttachments = ref([parentAttachment]) + const pendingSessionIntent = ref(null) const pendingForkBeforeMessageId = ref('parent-message') + const pendingQueueOwnerContext = ref(null) + const queuedRecords = new Map() + const pendingQueue = useChatPendingQueue({ + sessionKey, + ownerContext: pendingQueueOwnerContext, + inputText, + pendingAttachments, + pendingSessionIntent, + isStreaming: ref(false), + isBlocked: () => false, + autoResizeTextarea: vi.fn(), + sendCurrentInput: vi.fn(), + resetInputHistory: vi.fn(), + hasComposer: () => true, + pendingInputWal: { + put: async record => { queuedRecords.set(record.pendingInputId, structuredClone(record)) }, + list: async key => [...queuedRecords.values()].filter(record => record.sessionKey === key), + delete: async pendingInputId => { queuedRecords.delete(pendingInputId) }, + close: () => {}, + }, + }) + inputText.value = 'queued source draft' + expect(await pendingQueue.enqueuePendingInput(inputText.value)).toBe(true) + const queuedSourceId = pendingQueue.pendingQueue.value[0]!.pendingUiId + inputText.value = 'parent draft' + pendingAttachments.value = [parentAttachment] const delayed = delayedHandoffWal() const { api, options, rpc } = makeOptions({ sessionKey, inputText, pendingAttachments, + pendingSessionIntent, pendingForkBeforeMessageId, + pendingQueueOwnerContext, pendingInputWal: delayed.wal, }) const sending = api.onSend() await delayed.persistStarted + expect(pendingQueueOwnerContext.value).toMatchObject({ + sessionKey: parentSessionKey, + sourceSessionKey: parentSessionKey, + }) + expect(pendingQueue.editPendingItem(queuedSourceId)).toBe(false) + expect(pendingQueue.popPendingTail()).toBe(false) + expect(pendingQueue.popAllPendingIntoComposer()).toBe(false) sessionKey.value = childSessionKey inputText.value = 'child draft' pendingAttachments.value = [childAttachment] @@ -359,6 +396,72 @@ describe('useChatSend response-handoff persistence boundary', () => { expect(inputText.value).toBe('child draft') expect(pendingAttachments.value).toEqual([childAttachment]) expect(delayed.retained()).toBeNull() + expect(pendingQueueOwnerContext.value).toBeNull() + pendingQueue.cleanup() + }) + + it('releases the source fence when prepared handoff persistence fails', async () => { + let markPrepareStarted!: () => void + let releasePrepare!: () => void + const prepareStarted = new Promise(resolve => { markPrepareStarted = resolve }) + const prepareGate = new Promise(resolve => { releasePrepare = resolve }) + const pendingInputWal: PendingInputWal = { + put: async () => {}, + list: async () => [], + delete: async () => {}, + prepareHandoff: async () => { + markPrepareStarted() + await prepareGate + return { applied: false, record: null } + }, + compareAndSwapHandoff: async () => ({ applied: false, record: null }), + close: () => {}, + } + const pendingQueueOwnerContext = ref(null) + const { api, rpc } = makeOptions({ + messages: ref(usageReplayMessages()), + pendingInputWal, + pendingQueueOwnerContext, + }) + + const replay = api.sendUsageBarrierReplay({ + text: '/reset', + forkBeforeMessageId: 'usage-primary', + }) + await prepareStarted + expect(pendingQueueOwnerContext.value).toMatchObject({ + sessionKey: 'agent:main:webchat:test', + sourceSessionKey: 'agent:main:webchat:test', + }) + + releasePrepare() + + expect(await replay).toBe(false) + expect(rpc.call).not.toHaveBeenCalled() + expect(pendingQueueOwnerContext.value).toBeNull() + }) + + it('does not mistake its own source fence for a composer edit', async () => { + const sessionKey = ref('agent:main:webchat:persist-unchanged') + const inputText = ref('unchanged draft') + const pendingForkBeforeMessageId = ref('parent-message') + const delayed = delayedHandoffWal() + const { api, rpc } = makeOptions({ + sessionKey, + inputText, + pendingForkBeforeMessageId, + pendingInputWal: delayed.wal, + }) + rpc.call.mockResolvedValue({ sessionKey: sessionKey.value }) + + const sending = api.onSend() + await delayed.persistStarted + delayed.releasePersist() + await sending + + expect(rpc.call).toHaveBeenCalledOnce() + expect(inputText.value).toBe('') + expect(pendingForkBeforeMessageId.value).toBeNull() }) it('sends only the persisted snapshot while preserving newer same-session content', async () => { diff --git a/opensquilla-webui/src/composables/chat/useChatSend.ts b/opensquilla-webui/src/composables/chat/useChatSend.ts index df80f34a1..f4dbbf132 100644 --- a/opensquilla-webui/src/composables/chat/useChatSend.ts +++ b/opensquilla-webui/src/composables/chat/useChatSend.ts @@ -805,6 +805,11 @@ export function useChatSend(options: UseChatSendOptions) { ? context.ownerRequestId : null return currentOwnerRequestId === snapshot.queueOwnerRequestId + || Boolean( + snapshot.queueOwnerRequestId === null + && currentOwnerRequestId === activeResponseHandoff?.ownerRequestId + && activeResponseHandoff.requestSessionKey === options.sessionKey.value, + ) } function queueOwnerFromSnapshot(snapshot: ComposerSnapshot): PendingQueueOwner | undefined { @@ -1267,12 +1272,10 @@ export function useChatSend(options: UseChatSendOptions) { durableRecord, } activeResponseHandoff = gate - if (durableRecord) { - options.pendingQueueOwnerContext.value = { - sessionKey: requestSessionKey, - sourceSessionKey: requestSessionKey, - ownerRequestId, - } + options.pendingQueueOwnerContext.value = { + sessionKey: requestSessionKey, + sourceSessionKey: requestSessionKey, + ownerRequestId, } return gate } @@ -2942,12 +2945,35 @@ export function useChatSend(options: UseChatSendOptions) { return true } let durableHandoffRecord: ResponseHandoffWalRecord | null = null + const responseHandoffState: { gate: ResponseHandoffGate | null } = { gate: null } let consumeComposerSnapshotAfterHandoffPersistence = false + const ensureResponseHandoffSourceFence = (): ResponseHandoffGate | null => { + const currentAttempt = attempt + if (!currentAttempt) return null + responseHandoffState.gate ||= beginResponseHandoff( + requestSessionKey, + currentAttempt.clientRequestId, + durableHandoffRecord, + ) + return responseHandoffState.gate + } + const persistResponseHandoffIntoGate = async ( + requirePrepared = false, + ): Promise => { + const currentAttempt = attempt + const gate = ensureResponseHandoffSourceFence() + if (!currentAttempt || !gate) return null + const record = await persistResponseHandoff(currentAttempt, requirePrepared) + if (record) gate.durableRecord = record + return record + } const rejectBeforeDispatch = async (): Promise => { if (attempt && sendOpts.acceptedVisibleReplay) { sendOpts.rememberRetryableAttempt?.(attempt) } await discardUnsentResponseHandoff(durableHandoffRecord) + finishResponseHandoff(responseHandoffState.gate) + responseHandoffState.gate = null return 'not_sent' } const revalidateAfterHandoffPersistence = (): boolean => { @@ -3023,8 +3049,7 @@ export function useChatSend(options: UseChatSendOptions) { params, } if (attempt.forkBeforeMessageId) { - durableHandoffRecord = await persistResponseHandoff( - attempt, + durableHandoffRecord = await persistResponseHandoffIntoGate( sendOpts.requirePreparedHandoff, ) if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { @@ -3050,8 +3075,7 @@ export function useChatSend(options: UseChatSendOptions) { } } if (attempt.forkBeforeMessageId && !durableHandoffRecord) { - durableHandoffRecord = await persistResponseHandoff( - attempt, + durableHandoffRecord = await persistResponseHandoffIntoGate( sendOpts.requirePreparedHandoff, ) if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { @@ -3128,8 +3152,12 @@ export function useChatSend(options: UseChatSendOptions) { return rejectBeforeDispatch() } durableHandoffRecord = armed + if (responseHandoffState.gate) responseHandoffState.gate.durableRecord = armed if (!preDispatchAllowed('before_rpc')) { durableHandoffRecord = await disarmResponseHandoff(armed, attempt) || armed + if (responseHandoffState.gate) { + responseHandoffState.gate.durableRecord = durableHandoffRecord + } if (freshSendToken && activeFreshSendToken === freshSendToken) { activeFreshSendToken = null options.activeStreamTaskId.value = '' @@ -3140,15 +3168,7 @@ export function useChatSend(options: UseChatSendOptions) { } } options.aborted.value = false - let responseHandoff = ( - attempt.forkBeforeMessageId - ? beginResponseHandoff( - requestSessionKey, - attempt.clientRequestId, - durableHandoffRecord, - ) - : null - ) + if (attempt.forkBeforeMessageId) ensureResponseHandoffSourceFence() const acceptanceTransaction = beginAcceptanceTransaction( requestSessionKey, freshSendToken, @@ -3202,12 +3222,12 @@ export function useChatSend(options: UseChatSendOptions) { const accepted = noteAcceptedTask(res, requestSessionKey) const taskId = accepted.taskId const terminalStatus = terminalResponseStatus(res) - if (responseHandoff) { - responseHandoff.acceptedTaskId = taskId - responseHandoff.terminalResponse = Boolean(terminalStatus) + if (responseHandoffState.gate) { + responseHandoffState.gate.acceptedTaskId = taskId + responseHandoffState.gate.terminalResponse = Boolean(terminalStatus) } const stoppedByUser = acceptanceTransaction.stoppedByUser - || responseHandoff?.stoppedByUser === true + || responseHandoffState.gate?.stoppedByUser === true const lostFreshStream = !wasStreaming && !freshSendStillOwnsStream(freshSendToken, requestSessionKey) if (stoppedByUser || lostFreshStream) { @@ -3257,18 +3277,17 @@ export function useChatSend(options: UseChatSendOptions) { && options.sessionKey.value === requestSessionKey && acceptedSessionKey !== requestSessionKey ) { - durableHandoffRecord ||= await persistResponseHandoff(attempt) - responseHandoff ||= beginResponseHandoff( - requestSessionKey, - attempt.clientRequestId, - durableHandoffRecord, - ) + const responseHandoff = ensureResponseHandoffSourceFence()! + durableHandoffRecord ||= await persistResponseHandoffIntoGate() responseHandoff.stoppedByUser = true responseHandoff.acceptedTaskId = taskId responseHandoff.terminalResponse = Boolean(terminalStatus) await handoffResponseSession(acceptedSessionKey, responseHandoff) - } else if (responseHandoff && acceptedSessionKey === requestSessionKey) { - await handoffResponseSession(requestSessionKey, responseHandoff) + } else if ( + responseHandoffState.gate + && acceptedSessionKey === requestSessionKey + ) { + await handoffResponseSession(requestSessionKey, responseHandoffState.gate) } return 'accepted' } @@ -3302,17 +3321,13 @@ export function useChatSend(options: UseChatSendOptions) { responseSession: decision.responseSessionKey, current: options.sessionKey.value, }) - durableHandoffRecord ||= await persistResponseHandoff(attempt) - responseHandoff ||= beginResponseHandoff( - requestSessionKey, - attempt.clientRequestId, - durableHandoffRecord, - ) + const responseHandoff = ensureResponseHandoffSourceFence()! + durableHandoffRecord ||= await persistResponseHandoffIntoGate() responseHandoff.acceptedTaskId = taskId responseHandoff.terminalResponse = Boolean(terminalStatus) await handoffResponseSession(decision.responseSessionKey, responseHandoff) - } else if (responseHandoff && decision.reason === 'same_session') { - await handoffResponseSession(requestSessionKey, responseHandoff) + } else if (responseHandoffState.gate && decision.reason === 'same_session') { + await handoffResponseSession(requestSessionKey, responseHandoffState.gate) } else if (decision.reason === 'current_session_changed') { recordSessionNavigationDiag('send.response.stale', { requestSession: requestSessionKey, @@ -3373,7 +3388,7 @@ export function useChatSend(options: UseChatSendOptions) { } } const stoppedByUser = acceptanceTransaction.stoppedByUser - || responseHandoff?.stoppedByUser === true + || responseHandoffState.gate?.stoppedByUser === true if (stoppedByUser) { if (acceptedError?.terminalWithoutTask || rpcError?.accepted === false) { clearAcceptanceStop(acceptanceTransaction) @@ -3412,12 +3427,8 @@ export function useChatSend(options: UseChatSendOptions) { options.activeStreamSessionKey.value = '' options.stream.endStreaming() } - durableHandoffRecord ||= await persistResponseHandoff(attempt) - responseHandoff ||= beginResponseHandoff( - requestSessionKey, - attempt.clientRequestId, - durableHandoffRecord, - ) + const responseHandoff = ensureResponseHandoffSourceFence()! + durableHandoffRecord ||= await persistResponseHandoffIntoGate() responseHandoff.stoppedByUser = stoppedByUser responseHandoff.terminalResponse = acceptedError.terminalWithoutTask await handoffResponseSession(acceptedSessionKey, responseHandoff) @@ -3434,8 +3445,8 @@ export function useChatSend(options: UseChatSendOptions) { return 'accepted' } if (acceptedError && options.sessionKey.value === requestSessionKey) { - if (responseHandoff && acceptedSessionKey === requestSessionKey) { - await handoffResponseSession(requestSessionKey, responseHandoff) + if (responseHandoffState.gate && acceptedSessionKey === requestSessionKey) { + await handoffResponseSession(requestSessionKey, responseHandoffState.gate) } bindUserMessageId(attempt.clientMessageId, acceptedError.messageId) options.scheduleHistorySync() @@ -3461,11 +3472,11 @@ export function useChatSend(options: UseChatSendOptions) { options.activeStreamSessionKey.value = '' options.stream.endStreaming() } - if (responseHandoff && rpcError?.accepted === false) { + if (responseHandoffState.gate && rpcError?.accepted === false) { if (sendOpts.requirePreparedHandoff && rpcError.retryable !== false) { - await resetResponseHandoffForRetry(responseHandoff, attempt) + await resetResponseHandoffForRetry(responseHandoffState.gate, attempt) } else if (rpcError.retryable === false) { - await markResponseHandoffFailed(responseHandoff, err) + await markResponseHandoffFailed(responseHandoffState.gate, err) } } rememberRetryableAttempt(true) @@ -3481,7 +3492,7 @@ export function useChatSend(options: UseChatSendOptions) { } finally { attempt.acceptanceInFlight = false finishAcceptanceTransaction(acceptanceTransaction) - finishResponseHandoff(responseHandoff) + finishResponseHandoff(responseHandoffState.gate) } } @@ -3525,6 +3536,9 @@ export function useChatSend(options: UseChatSendOptions) { && (anchor.attachments?.length ?? 0) === 0, ) } + const replayOwnsResponseFence = () => ( + activeResponseHandoff?.ownerRequestId === stableClientRequestId + ) const replayIsBlocked = (allowOwnedStream = false) => ( options.sessionKey.value !== requestSessionKey || !replayAnchorIsCurrent() @@ -3533,10 +3547,13 @@ export function useChatSend(options: UseChatSendOptions) { || (!allowOwnedStream && options.stream.isStreaming.value) || hasAuthoritativeWork() || options.isCompactInFlightForCurrentSession() - || responseHandoffBlocksCurrentSession() + || (responseHandoffBlocksCurrentSession() && !replayOwnsResponseFence()) || Boolean(handoffRecoveryPromise) || options.hasPendingQueueWork?.() === true - || options.pendingQueueOwnerContext.value?.sessionKey === requestSessionKey + || ( + options.pendingQueueOwnerContext.value?.sessionKey === requestSessionKey + && options.pendingQueueOwnerContext.value.ownerRequestId !== stableClientRequestId + ) ) if (replayIsBlocked()) return false