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: [] }, } 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/useChatAttachments.test.ts b/opensquilla-webui/src/composables/chat/useChatAttachments.test.ts index c1c90f004..bdfd96d5b 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,515 @@ 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([]) + expect(attachments.hasPendingAttachmentWork()).toBe(true) + + attachments.retireAttachments() + expect(attachments.hasPendingAttachmentWork()).toBe(false) + const sessionBAttachment = nextSessionAttachment(1) + attachments.pendingAttachments.value = [sessionBAttachment] + settle(sniff) + await adding + + 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 => { + 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() + const fetchMock = vi.fn() + .mockImplementationOnce(() => sessionAUpload.promise) + .mockImplementationOnce(() => sessionBUpload.promise) + vi.stubGlobal('fetch', fetchMock) + 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' }, + ]) + 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) + 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('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)) + 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) @@ -428,6 +979,8 @@ describe('useChatAttachments', () => { const ready = await attachments.prepareAttachmentsForSend({ attachments: queuedAttachments, + ownership: 'detached', + isCurrent: () => true, }) expect(ready).toBe(true) @@ -478,7 +1031,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 b174b7a02..d7773df4c 100644 --- a/opensquilla-webui/src/composables/chat/useChatAttachments.ts +++ b/opensquilla-webui/src/composables/chat/useChatAttachments.ts @@ -28,19 +28,22 @@ 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 // cap) toast once instead of once per rejected file. type AttachmentBatch = { + generation: number totalSizeToastShown: boolean } @@ -115,10 +118,17 @@ 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 + // 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(() => - refreshInFlightAttachmentCount.value > 0 + attachmentIntakeInFlightCount.value > 0 + || composerRefreshInFlightCount.value > 0 || pendingAttachments.value.some( attachment => attachment.kind === 'inline_pending' || attachment.kind === 'uploading', ), @@ -133,8 +143,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 +156,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { return } await addAttachmentFile(file, batch) + if (!isAttachmentGenerationCurrent(batch.generation)) return } } @@ -150,6 +165,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' }) @@ -157,64 +173,99 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { } let mime = resolveAttachmentMime(file) - if (!isAllowedAttachmentMime(mime)) { - if (await fileLooksLikeUtf8Text(file)) { - // 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. } - // 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 (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) => { - 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 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 } - reader.onerror = () => { - const message = i18n.global.t('chat.toast.couldNotReadFile', { name: fileName }) - markAttachmentFailed(localId, file, mime, message) - pushToast(message, { tone: 'danger' }) + + if (!canStageAttachmentMime(mime)) { + pushToast(i18n.global.t('chat.toast.fileTooLarge', { name: fileName, cap: formatMiB(hardCap) }), { tone: 'danger' }) + return } - reader.readAsDataURL(file) - return - } - if (!canStageAttachmentMime(mime)) { - pushToast(i18n.global.t('chat.toast.fileTooLarge', { name: fileName, cap: formatMiB(hardCap) }), { tone: 'danger' }) - return + const placeholder: Attachment = { + kind: 'uploading', + local_id: localId, + name: fileName, + mime, + size: file.size, + file, + } + pendingAttachments.value.push(placeholder) + uploadAttachmentStaged(file, mime, placeholder, batch.generation).catch((err) => { + if (!isAttachmentGenerationCurrent(batch.generation)) return + 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) + } } - - pendingAttachments.value.push({ kind: 'uploading', local_id: localId, name: fileName, mime, size: file.size, file }) - uploadAttachmentStaged(file, mime, localId).catch((err) => { - 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, + placeholder: Attachment, + generation: number, + ) { const meta = await uploadAttachmentFile(file, mime) - const idx = pendingAttachments.value.findIndex(a => a.local_id === localId) + if (!isAttachmentGenerationCurrent(generation)) return + 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, @@ -235,6 +286,15 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { pendingAttachments.value.splice(index, 1) } + function retireAttachments() { + attachmentGeneration += 1 + refreshInFlightByCollection.delete(pendingAttachments.value) + composerPreparationInFlight = null + pendingAttachments.value = [] + composerRefreshInFlightCount.value = 0 + attachmentIntakeInFlightCount.value = 0 + } + async function retryAttachment(index: number) { const attachment = pendingAttachments.value[index] if (!attachment || attachment.kind !== 'failed') return @@ -252,8 +312,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', @@ -271,70 +334,104 @@ 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 = () => ( + (!composerOwned || isAttachmentGenerationCurrent(generation)) && isCurrent() + ) const attachments = options.attachments ?? pendingAttachments.value - const staged = [...attachments].filter(stagedUploadNeedsRefresh) - for (const attachment of staged) { - if (!isCurrent()) 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 - 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', + 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) + } + try { + 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 } - pushToast(`Upload expired for ${attachment.name}: select the file again`, { tone: 'danger' }) - return false - } - refreshInFlightAttachmentIds.add(attachment.local_id) - refreshInFlightAttachmentCount.value = refreshInFlightAttachmentIds.size - try { - const meta = await uploadAttachmentFile(attachment.file, attachment.mime) - if (!isCurrent()) return false - const currentIdx = attachments.findIndex(a => a.local_id === attachment.local_id) - 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, + 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) + } } - } catch (err: unknown) { - if (!isCurrent()) return false - const message = uploadFailureMessage(err) - markAttachmentFailed( - attachment.local_id, - attachment.file, - attachment.mime, - message, - attachments, - ) - 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 + } + return true + } finally { + if (composerPreparationInFlight === composerPreparation) { + composerPreparationInFlight = null } } - return true } function activeAttachmentCount(): number { 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) if (activeAttachments.length >= MAX_ATTACHMENTS) { pushToast(i18n.global.t('chat.toast.tooManyAttachments', { max: MAX_ATTACHMENTS }), { tone: 'danger' }) @@ -356,6 +453,10 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { return true } + function isAttachmentGenerationCurrent(generation: number): boolean { + return generation === attachmentGeneration + } + return { pendingAttachments, attachmentWorkBusy, @@ -363,6 +464,7 @@ export function useChatAttachments(artifactContent?: ArtifactContentAccess) { addAttachments, addAttachment, removeAttachment, + retireAttachments, retryAttachment, hasPendingAttachmentWork, prepareAttachmentsForSend, diff --git a/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts b/opensquilla-webui/src/composables/chat/useChatPendingQueue.test.ts index 853944a80..bf804c47b 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' @@ -115,12 +116,38 @@ 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 { + applied: false, + record: current ? structuredClone(current) : null, + } + const retained = { + ...structuredClone(record), + state: 'local_only' as const, + retainAfterCancel: true, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + } + records.set(record.pendingInputId, retained) + return { applied: true, record: 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 +158,7 @@ function memoryWal(initial: PendingInputWalRecord[] = []) { if (expectedWalRevisions[pendingInputId] !== revision) throw new Error('conflict') const next = { ...record, + sessionKey, position, walRevision: revision + 1, updatedAt: Date.now(), @@ -164,6 +192,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, @@ -184,6 +213,97 @@ 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.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' }, + expectedWalRevision, + equivalentSessionKeys, + ) + )) + return compareAndSwap +} + class TestBroadcastChannel { static readonly channels = new Map>() @@ -634,6 +754,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[] = [] @@ -1099,37 +1333,1302 @@ describe('useChatPendingQueue delivery state', () => { expect(second.queue.pendingQueue.value).toEqual([]) expect(records.size).toBe(0) }) - - await second.queue.hydratePendingQueue() - expect(second.queue.pendingQueue.value).toEqual([]) + + await second.queue.hydratePendingQueue() + expect(second.queue.pendingQueue.value).toEqual([]) + } finally { + first.queue.cleanup() + second.queue.cleanup() + vi.unstubAllGlobals() + TestBroadcastChannel.channels.clear() + } + }) + + 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('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! + 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' + await queue.enqueuePendingInput(inputText.value) + inputText.value = 'edit this surviving row' + await queue.enqueuePendingInput(inputText.value) + const removedId = pendingUiId(queue, 0) + const survivingId = pendingUiId(queue, 1) + + expect(queue.removePendingChip(removedId)).toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value.map(item => item.pendingUiId)).toEqual([survivingId]) + }) + + expect(queue.editPendingItem(survivingId)).toBe(true) + await vi.waitFor(() => { + expect(queue.pendingQueue.value).toEqual([]) + expect(inputText.value).toBe('edit this surviving row') + }) + 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('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( + 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('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', + 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', + '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('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('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.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('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' + 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: 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() + 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 + 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('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' + const { wal, records } = 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, + }]) + enableAtomicPendingMutations(wal, records) + 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, + }]) + + 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 { - first.queue.cleanup() - second.queue.cleanup() - vi.unstubAllGlobals() - TestBroadcastChannel.channels.clear() + 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' - await queue.enqueuePendingInput(inputText.value) - inputText.value = 'edit this surviving row' - await queue.enqueuePendingInput(inputText.value) - const removedId = pendingUiId(queue, 0) - const survivingId = pendingUiId(queue, 1) - - expect(queue.removePendingChip(removedId)).toBe(true) + 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(queue.pendingQueue.value.map(item => item.pendingUiId)).toEqual([survivingId]) + expect(first.queue.pendingQueue.value.map(item => item.text)).toEqual([ + 'legacy first', + 'canonical second', + ]) }) - expect(queue.editPendingItem(survivingId)).toBe(true) - await vi.waitFor(() => { - expect(queue.pendingQueue.value).toEqual([]) - expect(inputText.value).toBe('edit this surviving row') + 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, }) - queue.cleanup() + 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 () => { @@ -1236,6 +2735,266 @@ 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', + }) + expect(records.get(pendingInputId)!.walRevision).toBeGreaterThanOrEqual(2) + await vi.waitFor(() => expect(pendingInputQueue.cancel).toHaveBeenCalled()) + expect(wal.compareAndSwapPendingInput).toHaveBeenCalledWith( + expect.objectContaining({ state: 'staged' }), + 1, + expect.any(Array), + ) + } finally { + queue.cleanup() + } + }) + + 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, @@ -2358,6 +4117,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 bb218e55d..08685860c 100644 --- a/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts +++ b/opensquilla-webui/src/composables/chat/useChatPendingQueue.ts @@ -6,12 +6,14 @@ 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 { isSendableAttachment, serializeSendableAttachment, } from '@/utils/chat/attachments' +import { canonicalSessionKey } from '@/utils/chat/sessionKeys' import type { AcceptedHandoffCommit, PendingInputWal, @@ -99,10 +101,14 @@ export interface PendingQueueOwner { export interface PendingCancelOptions { retainAfterCancel?: boolean + /** Internal post-restore cleanup must not invalidate sibling restores. */ + invalidateRestore?: boolean } export interface PendingQueueOwnerContext { sessionKey: string + /** Original visible queue fenced until response-session adoption settles. */ + sourceSessionKey?: string ownerRequestId: string } @@ -134,10 +140,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 @@ -160,9 +165,68 @@ 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' }, + ) + const ownerContextKey = (context: PendingQueueOwnerContext | null | undefined) => ( + context + ? [ + 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.sourceSessionKey) === 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) @@ -172,7 +236,11 @@ 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[] = [] const removedIdentities = new Set() @@ -261,7 +329,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, @@ -308,7 +376,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, @@ -330,8 +398,40 @@ 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 walRecordOwnsItem( + record: PendingInputWalRecord, + item: ChatPendingItem, + sessionKey: string, + ): boolean { + return queueSessionKey(record.sessionKey) === queueSessionKey(sessionKey) + && walRecordMatchesItemIdentity(record, item) + } + + function walRecordMatchesItemIdentity( + record: PendingInputWalRecord, + item: ChatPendingItem, + ): boolean { + return record.clientRequestId === item.pendingClientRequestId + && record.clientMessageId === item.pendingClientMessageId + } + function removedIdentity(sessionKey: string, pendingInputId: string): string { - return `${sessionKey}\u0000${pendingInputId}` + return `${queueSessionKey(sessionKey)}\u0000${pendingInputId}` } function rememberRemoval(sessionKey: string, pendingInputId: string) { @@ -353,24 +453,32 @@ 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) { - 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, }) } @@ -381,7 +489,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { action: 'changed' | 'removed' = 'changed', ) { try { - broadcast?.postMessage({ sessionKey, pendingInputId, action }) + broadcast?.postMessage({ + sessionKey: queueSessionKey(sessionKey), + pendingInputId, + action, + }) } catch {} } @@ -407,6 +519,88 @@ 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) + 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 + } + function ordinaryDurableItem(item: ChatPendingItem): boolean { return Boolean( item.pendingInputId @@ -466,14 +660,14 @@ 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 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 @@ -486,20 +680,26 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { isCurrent: () => pendingQueue.value.some(candidate => ( candidate.pendingInputId === pendingInputId )), + 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 } @@ -513,9 +713,9 @@ 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: item.ownerSessionKey || options.sessionKey.value, + key: queueSessionKey(item.ownerSessionKey), pendingInputId, clientRequestId: item.pendingClientRequestId, clientMessageId: item.pendingClientMessageId, @@ -555,7 +755,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) { @@ -593,19 +793,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) @@ -622,12 +828,16 @@ 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 + 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 } if ( @@ -644,28 +854,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 @@ -678,8 +904,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 @@ -690,7 +916,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 @@ -702,14 +928,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 : [] @@ -718,8 +948,13 @@ 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)) { + 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) @@ -741,7 +976,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } : {}), ...(serverItem.confirmedPlainText === true ? { confirmedPlainText: true } : {}), - ownerSessionKey: sessionKey, + ownerSessionKey, pendingInputId, pendingClientRequestId: clientRequestId, pendingClientMessageId: clientMessageId, @@ -773,13 +1008,13 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { ? serverItem.position : item.pendingPosition item.pendingMayHaveServerCopy = true - await writeWalItem(item, 'staged') + await writeStagingWalItem(item, 'staged', ownerSessionKey) } 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 @@ -792,8 +1027,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') { @@ -804,9 +1039,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 @@ -822,12 +1058,15 @@ 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 : '' if (!sessionKey) return if (message?.action === 'removed' && pendingInputId) { + invalidateCancellation(pendingInputId) rememberRemoval(sessionKey, pendingInputId) removePendingIdentity(sessionKey, pendingInputId) void options.pendingInputWal?.delete(pendingInputId).catch(() => {}) @@ -836,7 +1075,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { void cancelServerIdentity(sessionKey, pendingInputId).catch(() => {}) return } - if (sessionKey === options.sessionKey.value) { + if (sessionKey === queueSessionKey()) { void hydratePendingQueue(sessionKey) } } @@ -846,7 +1085,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 } @@ -876,7 +1115,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() @@ -904,7 +1143,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 })() @@ -973,7 +1212,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 => ( @@ -993,7 +1232,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, @@ -1033,7 +1272,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', @@ -1051,7 +1290,8 @@ 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) + if (action === 'removed') invalidateCancellation(item.pendingInputId) await options.pendingInputWal.delete(item.pendingInputId) if (action === 'removed') rememberRemoval(sessionKey, item.pendingInputId) broadcastChange(sessionKey, item.pendingInputId, action) @@ -1060,7 +1300,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 @@ -1071,7 +1315,44 @@ 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 mutation = await options.pendingInputWal.retainCancelled( + { + ...walRecordForItem(item, 'local_only'), + walRevision: expectedWalRevision + 1, + }, + expectedWalRevision, + walLookupSessionKeys(sessionKey), + ) + 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. + if ( + mutation.record.state !== 'cancelling' + || mutation.record.retainAfterCancel === true + ) { + forgetRemoval(sessionKey, item.pendingInputId!) + } + if (walRecordOwnsItem(mutation.record, item, sessionKey)) { + replaceItemFromWalRecord(item, mutation.record) + } + return false + } + removePendingIdentity(sessionKey, item.pendingInputId!) + return false + } + const retained = mutation.record! + 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 { @@ -1085,15 +1366,21 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { } } - async function cancelDurableItem( + async function performDurableCancellation( item: ChatPendingItem, cancelOptions: PendingCancelOptions = {}, ): Promise { if (!durableItem(item)) return true const previousState = item.pendingPersistenceState || 'saving' - const sessionKey = item.ownerSessionKey || options.sessionKey.value + let sessionKey = queueSessionKey(item.ownerSessionKey) const retainAfterCancel = cancelOptions.retainAfterCancel === true - if (item.pendingRetainAfterCancel === true && !retainAfterCancel) { + const expectedInvalidation = cancellationInvalidations.get(item.pendingInputId!) ?? 0 + if ( + item.pendingRetainAfterCancel === true + && !retainAfterCancel + && item.pendingPersistenceState === 'local_only' + && item.pendingMayHaveServerCopy === false + ) { try { await forgetDurableItem(item, 'removed') return true @@ -1106,15 +1393,78 @@ 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, + walLookupSessionKeys(sessionKey), + ) + 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 = walRecordOwnsItem(mutation.record, item, sessionKey) + const migratedIdentity = !retainAfterCancel + && !sameIdentity + && walRecordMatchesItemIdentity(mutation.record, item) + 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 + } + if (migratedIdentity) { + replaceItemFromWalRecord(item, mutation.record) + sessionKey = queueSessionKey(mutation.record.sessionKey) + 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 } @@ -1128,7 +1478,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 { @@ -1146,13 +1498,15 @@ 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 } : {}), }) - if (retainAfterCancel) return retainCancelledDraft(item, sessionKey) + if (retainAfterCancel) { + return retainCancelledDraft(item, sessionKey, expectedInvalidation) + } await forgetDurableItem(item, 'removed') return true } catch { @@ -1164,14 +1518,52 @@ 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 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 + 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)?.promise === operation) { + cancellationOperations.delete(pendingInputId) + } + }) + cancellationOperations.set(pendingInputId, { promise: operation, retainAfterCancel }) + 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 @@ -1180,15 +1572,11 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { && item.pendingRetainAfterCancel === true ) return true removePendingIdentity( - item.ownerSessionKey || options.sessionKey.value, + queueSessionKey(item.ownerSessionKey), pendingInputId, ) return true - }).finally(() => { - cancellationOperations.delete(pendingInputId) }) - cancellationOperations.set(pendingInputId, operation) - return operation } function pendingIndex(pendingUiId: string): number { @@ -1208,8 +1596,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 } @@ -1284,6 +1674,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { function clearPendingQueue() { cancelPendingReorder() clearPendingDrainAfterTerminalTimer() + activeQueueLease += 1 for (const item of [...pendingQueue.value]) { if ( item.deliveryState === 'steering' @@ -1329,8 +1720,15 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (!shouldCommit()) return cancelPendingReorder() clearPendingDrainAfterTerminalTimer() - const sourceSessionKey = options.sessionKey.value + activeQueueLease += 1 + 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, [ @@ -1340,10 +1738,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( @@ -1351,8 +1749,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 => { @@ -1387,6 +1789,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() @@ -1413,15 +1816,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) @@ -1433,9 +1841,12 @@ 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(item.ownerSessionKey || options.sessionKey.value, item.pendingInputId) + broadcastChange(queueSessionKey(item.ownerSessionKey), item.pendingInputId) } } @@ -1448,7 +1859,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, @@ -1460,6 +1872,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[] = [] @@ -1471,7 +1884,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 @@ -1500,7 +1913,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) @@ -1516,6 +1931,104 @@ 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, + lease = { + ownerSessionKey: queueSessionKey(item.ownerSessionKey), + queueLease: activeQueueLease, + composerRevision, + cancellationInvalidation: item.pendingInputId + ? cancellationInvalidations.get(item.pendingInputId) ?? 0 + : 0, + }, + ) { + void cancelDurableItem(item, { retainAfterCancel: true }).then(retained => { + if ( + !retained + || disposed + || queueSessionKey(item.ownerSessionKey) !== lease.ownerSessionKey + || 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 + 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. + void cancelDurableItem(item, { invalidateRestore: false }) + }) + } + + function restoreDurableItemsIntoComposerInOrder( + items: ChatPendingItem[], + restore: (item: ChatPendingItem) => void, + lease: { + ownerSessionKey: string + queueLease: number + composerRevision: number + cancellationInvalidations: Map + }, + ) { + 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 ( + 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 + // 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) + lease.composerRevision = composerRevision + // Each retained row is removed only after its ordered composer commit. + void cancelDurableItem(item, { invalidateRestore: false }) + } + }) + } + function editPendingItem(pendingUiId: string): boolean { const index = pendingIndex(pendingUiId) const item = pendingQueue.value[index] @@ -1531,6 +2044,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { || item.promptAnnotationIds?.length || item.pendingPersistenceState === 'saving' || item.pendingPersistenceState === 'cancelling' + || responseHandoffOwnsCurrentQueue() || hasUneditablePendingAttachments(item) ) return false const restore = () => { @@ -1545,7 +2059,7 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { : attachment )) options.pendingAttachments.value = [ - ...restoredAttachments, + ...collisionFreeComposerAttachments(restoredAttachments), ...options.pendingAttachments.value, ] options.pendingSessionIntent.value = ( @@ -1554,12 +2068,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) @@ -1568,6 +2077,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. @@ -1585,12 +2095,12 @@ 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.pendingAttachments.value = collisionFreeComposerAttachments( + tail.attachments || [], + [], + ) options.pendingSessionIntent.value = tail.intent || null options.autoResizeTextarea() }) @@ -1598,13 +2108,17 @@ 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 } function popAllPendingIntoComposer(): boolean { + if (responseHandoffOwnsCurrentQueue()) return false cancelPendingReorder() clearPendingDrainAfterTerminalTimer() if (!options.hasComposer() || pendingQueue.value.length === 0) return false @@ -1616,12 +2130,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) @@ -1630,31 +2138,44 @@ 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() - 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) - 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() - }) - } + const restoreLease = { + 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] + .filter(Boolean) + .join('\n') + options.pendingAttachments.value = [ + ...options.pendingAttachments.value, + ...collisionFreeComposerAttachments(item.attachments || []), + ] + options.pendingSessionIntent.value = ( + options.pendingSessionIntent.value || item.intent || null + ) + options.autoResizeTextarea() + options.resetInputHistory() + }, restoreLease) return true } @@ -1662,8 +2183,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 } @@ -1675,7 +2196,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, @@ -1713,7 +2234,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 { @@ -1732,12 +2253,15 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { head.deliveryState = 'steering' nextTick(() => { if ( - options.sessionKey.value !== ownerSessionKey + queueSessionKey() !== ownerSessionKey || pendingQueue.value[0] !== head ) 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() }) @@ -1850,6 +2374,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 || '')! @@ -1872,7 +2438,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() @@ -1882,7 +2448,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 @@ -1935,9 +2501,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { if (snapshot.mode === 'local') { try { const result = await options.pendingInputWal!.commitOrder!( - options.sessionKey.value, + 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) { @@ -1945,10 +2512,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 } @@ -1957,16 +2524,22 @@ 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 { + } 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() @@ -2003,7 +2576,10 @@ export function useChatPendingQueue(options: UseChatPendingQueueOptions) { function cleanup() { cancelPendingReorder() disposed = true + 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 bd671de05..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' @@ -271,6 +272,244 @@ 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 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] + 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() + 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 () => { + 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() @@ -1273,11 +1512,91 @@ 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() }) + 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, local_id: -1 }, + newAttachment, + ]) + expect(new Set(pendingAttachments.value.map(attachment => attachment.local_id)).size).toBe(2) + 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' @@ -1356,6 +1675,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 +3262,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' }, @@ -3339,6 +3667,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 326a49926..f4dbbf132 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' @@ -421,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 @@ -553,10 +561,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 }, @@ -800,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 { @@ -1262,8 +1272,10 @@ export function useChatSend(options: UseChatSendOptions) { durableRecord, } activeResponseHandoff = gate - if (durableRecord) { - options.pendingQueueOwnerContext.value = { sessionKey: requestSessionKey, ownerRequestId } + options.pendingQueueOwnerContext.value = { + sessionKey: requestSessionKey, + sourceSessionKey: requestSessionKey, + ownerRequestId, } return gate } @@ -1562,6 +1574,7 @@ export function useChatSend(options: UseChatSendOptions) { if (activeResponseHandoff === gate) { options.pendingQueueOwnerContext.value = { sessionKey: key, + sourceSessionKey: gate.requestSessionKey, ownerRequestId: gate.ownerRequestId, } } @@ -1703,17 +1716,30 @@ export function useChatSend(options: UseChatSendOptions) { .filter(Boolean) .join('\n') } - const existingAttachmentIds = new Set( + const usedLocalIds = new Set( options.pendingAttachments.value.map(attachment => attachment.local_id), ) - const missingAttachments = record.recoveryAttachments.filter(attachment => ( - !existingAttachmentIds.has(attachment.local_id) - )) + let nextRecoveredLocalId = -1 + const missingAttachments = record.recoveryAttachments.flatMap((attachment, index) => { + const owner = responseHandoffAttachmentOwner(record.ownerRequestId, index) + if (options.pendingAttachments.value.some(candidate => ( + responseHandoffAttachmentOwners.get(candidate) === 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 = [ - ...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 @@ -1801,6 +1827,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 +2316,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 +2372,7 @@ export function useChatSend(options: UseChatSendOptions) { }) ) { await dispatchSend(text, { + attachmentOwnership: 'composer', composerText, promptAnnotationIds: recoveredAttempt.promptAnnotationIds, queueMode: recoveredAttempt.queueMode, @@ -2483,6 +2512,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 +2674,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 +2717,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 +2847,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' @@ -2909,13 +2945,48 @@ 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 => { + 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 @@ -2978,14 +3049,13 @@ export function useChatSend(options: UseChatSendOptions) { params, } if (attempt.forkBeforeMessageId) { - durableHandoffRecord = await persistResponseHandoff( - attempt, + durableHandoffRecord = await persistResponseHandoffIntoGate( sendOpts.requirePreparedHandoff, ) if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { return rejectBeforeDispatch() } - if (!preDispatchAllowed()) return rejectBeforeDispatch() + if (!revalidateAfterHandoffPersistence()) return rejectBeforeDispatch() } if (!sendOpts.acceptedVisibleReplay) { const now = new Date().toISOString() @@ -3005,14 +3075,13 @@ export function useChatSend(options: UseChatSendOptions) { } } if (attempt.forkBeforeMessageId && !durableHandoffRecord) { - durableHandoffRecord = await persistResponseHandoff( - attempt, + durableHandoffRecord = await persistResponseHandoffIntoGate( sendOpts.requirePreparedHandoff, ) if (sendOpts.requirePreparedHandoff && !durableHandoffRecord) { return rejectBeforeDispatch() } - if (!preDispatchAllowed()) return rejectBeforeDispatch() + if (!revalidateAfterHandoffPersistence()) return rejectBeforeDispatch() } if (!preDispatchAllowed()) return rejectBeforeDispatch() if (!preserveComposer) options.closeSlashMenu() @@ -3039,6 +3108,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. @@ -3068,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 = '' @@ -3080,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, @@ -3142,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) { @@ -3197,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' } @@ -3242,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, @@ -3313,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) @@ -3352,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) @@ -3374,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() @@ -3401,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) @@ -3421,7 +3492,7 @@ export function useChatSend(options: UseChatSendOptions) { } finally { attempt.acceptanceInFlight = false finishAcceptanceTransaction(acceptanceTransaction) - finishResponseHandoff(responseHandoff) + finishResponseHandoff(responseHandoffState.gate) } } @@ -3465,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() @@ -3473,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 @@ -3504,6 +3581,7 @@ export function useChatSend(options: UseChatSendOptions) { usageBarrierReplayInFlight = true try { const outcome = await dispatchSend(text, { + attachmentOwnership: 'detached', payload: { attachments: [], intent: null, @@ -3548,7 +3626,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 @@ -3561,12 +3639,46 @@ 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 }> = [] + 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 => ( + isSendableAttachment(candidate) + && candidate.local_id === attachment.local_id + && JSON.stringify(serializeSendableAttachment(candidate)) + === JSON.stringify(serializeSendableAttachment(attachment)) + )) + if (current) { + responseHandoffAttachmentOwners.set(current, owner) + } else { + 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) { + 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/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..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 type { ChatMessage } from '@/types/chat' +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 { @@ -16,6 +22,93 @@ function emptyUsage(): ChatUsageAccumulator { } } +function runtimeHarness( + initialSessionKey: string, + generatedSessionKey = 'agent:main:webchat:generated', + queueBindings: { + sessionKey?: Ref + switchPendingQueue?: UseChatSessionRuntimeOptions['switchPendingQueue'] + adoptPendingQueue?: UseChatSessionRuntimeOptions['adoptPendingQueue'] + } = {}, +) { + 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(queueBindings.switchPendingQueue || (() => {})) + const adoptPendingQueue = vi.fn(queueBindings.adoptPendingQueue || (() => {})) + 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') @@ -23,6 +116,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 +155,7 @@ describe('useChatSessionRuntime Meta draft recovery', () => { resetSavingsPopupCooldown: vi.fn(), restoreWidgetState: vi.fn(), resetStreamLiveTurnState: vi.fn(), + retireAttachments, }) await expect(runtime.rebindDraftSession( @@ -78,6 +173,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 +222,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 +261,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 +286,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 +296,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 +330,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 +345,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 +354,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 +384,283 @@ 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 canonical adoption', async () => { + const sessionKey = ref('agent:main:webchat:a') + 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([]), + 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.adoptMaterializedSession('agent:main:webchat:b') + await runtime.adoptResponseSession('agent:main:webchat:c', 'request-a') + + 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([]) + }) + + 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', + ]) + }) + + 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() + } }) }) diff --git a/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts b/opensquilla-webui/src/composables/chat/useChatSessionRuntime.ts index 01b819854..e32e467db 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 @@ -79,6 +80,7 @@ export interface UseChatSessionRuntimeOptions { resetSavingsPopupCooldown: () => void restoreWidgetState: () => void resetStreamLiveTurnState: () => void + retireAttachments?: () => void resetDraftComposer?: () => void } @@ -120,7 +122,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { return ( handoffEpoch === epoch && handoffTargetKey === targetKey - && options.sessionKey.value === sourceKey + && canonicalSessionKey(options.sessionKey.value) === sourceKey ) } @@ -181,28 +183,30 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { key: string, pendingQueuePolicy: | { kind: 'navigate' } + | { kind: 'draft_materialization' } | { 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, ) @@ -217,14 +221,15 @@ 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 // 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,7 +251,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { () => { if ( handoffEpoch === epoch - && options.sessionKey.value === key + && canonicalSessionKey(options.sessionKey.value) === targetKey ) { void Promise.resolve() .then(() => options.loadCurrentSessionUsage()) @@ -268,7 +273,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { if ( subscriptionOutcome.cancelled === true && handoffEpoch === epoch - && options.sessionKey.value === key + && canonicalSessionKey(options.sessionKey.value) === targetKey ) { const current = options.currentSessionBootstrap?.() if (current && current.generation !== bootstrap.generation) { @@ -279,7 +284,10 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { subscriptionOutcome = await current.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 @@ -297,25 +305,31 @@ 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, ): 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, ) @@ -332,7 +346,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' }) @@ -345,7 +359,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 } @@ -353,13 +368,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, ) @@ -372,9 +391,10 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { finishHandoff(epoch, 'superseded') return } + 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. @@ -393,6 +413,7 @@ export function useChatSessionRuntime(options: UseChatSessionRuntimeOptions) { resetCurrentSessionAfterSlash, startDraftSession, switchToSession, + adoptMaterializedSession, adoptResponseSession, rebindDraftSession, } diff --git a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts index 914346302..8c9f2a038 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.atomicHandoff.test.ts @@ -269,7 +269,219 @@ class ControlledObjectStore { } } -describe('BrowserPendingInputWal atomic handoff cancellation', () => { +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('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) + 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.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({ + 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() + }) + + 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) @@ -362,4 +574,58 @@ describe('BrowserPendingInputWal atomic handoff cancellation', () => { 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 0e565aa98..56de61f4d 100644 --- a/opensquilla-webui/src/utils/chat/pendingInputWal.ts +++ b/opensquilla-webui/src/utils/chat/pendingInputWal.ts @@ -69,6 +69,18 @@ export interface PendingInputOrderCommit { records: PendingInputWalRecord[] } +export interface PendingInputWalMutation { + applied: boolean + /** The current live row, or null only when no valid row owns this identity. */ + 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[] @@ -84,10 +96,41 @@ 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, + 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 + /** 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[], expectedWalRevisions: Record, + equivalentSessionKeys?: string[], ) => Promise putHandoff?: (record: ResponseHandoffWalRecord) => Promise /** Atomically create a handoff without replacing another dispatcher's record. */ @@ -323,6 +366,178 @@ class BrowserPendingInputWal implements PendingInputWal { await transactionDone(transaction) } + async beginCancellation( + 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 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, + 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.state !== 'cancelling' + || raw.retainAfterCancel !== true + || (raw.walRevision ?? 1) !== expectedWalRevision + ) { + await transactionDone(transaction) + return { + applied: false, + record: isPendingInputWalRecord(raw) ? cloneRecord(raw) : null, + } + } + const retained = cloneRecord({ + ...record, + state: 'local_only', + retainAfterCancel: true, + walRevision: expectedWalRevision + 1, + updatedAt: Date.now(), + }) + store.put(retained) + await transactionDone(transaction) + 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 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') @@ -348,17 +563,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 +591,7 @@ class BrowserPendingInputWal implements PendingInputWal { } const next = cloneRecord({ ...record, + sessionKey, position, walRevision: currentRevision + 1, updatedAt: Date.now(), @@ -516,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, 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.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 7db5e3966..2ddb706cd 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -945,6 +945,7 @@ import { optionalSessionRpcCallOptions, } from '@/composables/chat/sessionBootstrapAdmission' import { useChatSessionRuntime } from '@/composables/chat/useChatSessionRuntime' +import { switchChatViewSession } from '@/views/chatViewSessionNavigation' import { useChatSessionSubscription, } from '@/composables/chat/useChatSessionSubscription' @@ -1845,6 +1846,7 @@ const { onFileInputChange, addAttachments, removeAttachment, + retireAttachments, retryAttachment, hasPendingAttachmentWork, prepareAttachmentsForSend, @@ -2952,9 +2954,9 @@ const chatSessionRuntime = useChatSessionRuntime({ resetSavingsPopupCooldown, restoreWidgetState, resetStreamLiveTurnState, + retireAttachments, resetDraftComposer: () => { inputText.value = '' - pendingAttachments.value = [] resetComposerInputHistory() autoResizeTextarea() }, @@ -2963,17 +2965,26 @@ const { resetCurrentSessionAfterSlash, startDraftSession, switchToSession: switchRuntimeToSession, + adoptMaterializedSession: adoptRuntimeMaterializedSession, adoptResponseSession, rebindDraftSession, } = chatSessionRuntime 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) { + return switchChatViewSession( + nextSessionKey, + adoptRuntimeMaterializedSession, + handleAuthoritativeSessionSubscription, + ) } const metaSkillSetup = useMetaSkillSetup({ @@ -3125,7 +3136,7 @@ const chatGoals = useChatGoals({ ) return '' } if (workspaceId) freshTaskDraft.bindMaterializedProjectTask(key, workspaceId) - await switchToSession(key) + await adoptMaterializedSession(key) return key }, ensureSubscribed: async key => { @@ -3586,6 +3597,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 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 +}