From 0717870d9fa130a904bba4647f9490daf8dc86ac Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:13:16 +0200 Subject: [PATCH] fix: keep a detached job's card unsettled in a mid-turn save --- .../copilot/chat/AIChatManager.svelte.ts | 25 +++++++++++++++---- .../copilot/chat/AIChatManager.test.ts | 16 ++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a84bc1b31f..d8db254157 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -4597,10 +4597,15 @@ export class AIChatManager { // through here first. private settledToolDisplay = ( messages: DisplayMessage[], - messageText: string + messageText: string, + shouldSettle: (message: ToolDisplayMessage) => boolean = () => true ): DisplayMessage[] => messages.map((message) => { - if (message.role === 'tool' && (message.isLoading || message.isQueued)) { + if ( + message.role === 'tool' && + (message.isLoading || message.isQueued) && + shouldSettle(message) + ) { // Stopping the turn does not stop the job, and between Run and the job's id // there is no way to know whether the server queued one: nothing threads the // abort into that request, so it lands either way. That window says so @@ -4652,9 +4657,19 @@ export class AIChatManager { /** What the transcript would be if the turn stopped here — for the writes that fire * mid-turn without ending it. Loading is a property of this page: reloading resolves * no card, so one stored still pending comes back asking for input nothing can - * deliver. Settles the stored copy only; the live turn keeps its cards. */ - #interruptedSnapshot = (): DisplayMessage[] => - this.settledToolDisplay(this.displayMessages, 'Interrupted') + * deliver. Settles the stored copy only; the live turn keeps its cards. + * + * Except a detached job's card, which the poller does resolve after a reload: settling + * that one stores an "Interrupted" error, and the patch a completed job merges in + * carries no error to clear it with. */ + #interruptedSnapshot = (): DisplayMessage[] => { + const detached = new Set(this.backgroundJobs.filter((j) => j.detached).map((j) => j.toolCallId)) + return this.settledToolDisplay( + this.displayMessages, + 'Interrupted', + (message) => !detached.has(message.tool_call_id) + ) + } } export const aiChatManager = new AIChatManager() diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 26e26452e0..78e1d9919e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -3872,6 +3872,22 @@ describe('AIChatManager background job completion', () => { expect(manager.pendingJobNotes[0]).toContain('"rowCount": 2') }) + // Detaching persists while the card is still loading. Storing it as interrupted would + // stick, because the patch a completed job merges in carries no error to clear. + it("stores a detached job's card unsettled, so a later success is not left an error", async () => { + const manager = new AIChatManager() + manager.registerJob(datatableJob) + manager.applyToolStatus('tc-1', { content: 'running in background', isLoading: true }) + const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined) + + manager.markJobDetached('job-1') + await vi.waitFor(() => expect(saveChat).toHaveBeenCalled()) + + const stored = (saveChat.mock.calls.at(-1)?.[0] as any[]).find((m) => m.tool_call_id === 'tc-1') + expect(stored.error).toBeUndefined() + expect(stored.content).toBe('running in background') + }) + it('skips reconstruction and emits no note for a canceled detached job', async () => { const manager = new AIChatManager() manager.registerJob(datatableJob)