From 0ded5f96f2aa98c2783a818aa8d293dc598cf3c1 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:59:28 +0200 Subject: [PATCH] fix: settle no card the poller will resolve after a reload --- .../copilot/chat/AIChatManager.svelte.ts | 16 +++++++++----- .../copilot/chat/AIChatManager.test.ts | 22 ++++++++++++++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index d8db254157..126a975f69 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -4659,15 +4659,21 @@ export class AIChatManager { * 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. * - * 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. */ + * Except a card the poller resolves 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. Which cards those are is loadPastChat's question, so ask it the same + * way — a job still running inline is detached by the restore and polled like any + * other. */ #interruptedSnapshot = (): DisplayMessage[] => { - const detached = new Set(this.backgroundJobs.filter((j) => j.detached).map((j) => j.toolCallId)) + const polled = new Set( + this.backgroundJobs + .filter((j) => j.detached || this.isJobNonTerminal(j.status)) + .map((j) => j.toolCallId) + ) return this.settledToolDisplay( this.displayMessages, 'Interrupted', - (message) => !detached.has(message.tool_call_id) + (message) => !polled.has(message.tool_call_id) ) } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 78e1d9919e..20169b2de5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -261,9 +261,6 @@ describe('AIChatManager run form', () => { expect(manager.displayMessages[0].isLoading).toBe(true) }) - // The tool reads the deployed schema before it asks for arguments. A stop during that - // read drains the callbacks and settles the card, so a waiter installed afterwards was - // one no rendered form could resolve: the turn stayed loading until a second stop. it('installs no run-form waiter once the turn is stopped', async () => { const manager = new AIChatManager() // The turn the tool is running under; cancel aborts it. @@ -3888,6 +3885,25 @@ describe('AIChatManager background job completion', () => { expect(stored.content).toBe('running in background') }) + // A job still waiting inline is detached by the restore and polled like any other, so + // its card is one the poller resolves too — storing it as interrupted sticks, for the + // same reason an already-detached one would. + it("stores an inline job's card unsettled, so a later success is not left an error", async () => { + const manager = new AIChatManager() + manager.registerJob(datatableJob) + manager.registerJob({ ...datatableJob, jobId: 'job-2', toolCallId: 'tc-2' }) + manager.applyToolStatus('tc-1', { content: 'running', isLoading: true }) + const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined) + + // The other job reaching a terminal status is what fires the save; job-1 is still + // inside its inline wait when it lands. + manager.updateJob('job-2', { status: 'success' }) + 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() + }) + it('skips reconstruction and emits no note for a canceled detached job', async () => { const manager = new AIChatManager() manager.registerJob(datatableJob)