From a4f500e0ab76edbc762aeb907d00275e969f5873 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:46:54 +0200 Subject: [PATCH] fix: settle pending tool cards in mid-turn saves, and open the form for args the model cannot supply Co-Authored-By: Claude Opus 5 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 16 +++++----- .../copilot/chat/AIChatManager.test.ts | 31 ++++++++++++++++--- .../components/copilot/chat/global/core.ts | 2 +- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index f502cfff00..8b7411d363 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -811,7 +811,7 @@ export class AIChatManager { this.#maskPersistQueue = this.#maskPersistQueue.then(() => this.historyManager .saveChat( - this.displayMessages, + this.#interruptedSnapshot(), this.messages, this.contextUsage, this.modifiedItems ? [...this.modifiedItems] : undefined @@ -1135,7 +1135,7 @@ export class AIChatManager { this.#jobPersistQueue = this.#jobPersistQueue.then(() => this.historyManager .saveChat( - this.displayMessages, + this.#interruptedSnapshot(), this.messages, this.contextUsage, undefined, @@ -1836,11 +1836,6 @@ export class AIChatManager { : message ) if (!callback) { - // Nothing will resume the turn, so no later save carries this settled card the - // way the live path's end-of-turn write does. - void this.historyManager - .saveChat(this.displayMessages, this.messages, this.contextUsage) - .catch((e) => console.error('Failed to persist cancelled run form', e)) return } callback(undefined) @@ -4632,6 +4627,13 @@ export class AIChatManager { cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => { this.displayMessages = this.settledToolDisplay(this.displayMessages, messageText) } + + /** 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') } 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 243b281246..1be5b08b66 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -243,7 +243,6 @@ describe('AIChatManager run form', () => { } ] expect(manager.isRunFormPending('call_r')).toBe(false) - const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined) manager.handleRunFormCancel('call_r') @@ -252,9 +251,33 @@ describe('AIChatManager run form', () => { expect(settled.runForm?.canceled).toBe(true) expect(settled.isLoading).toBe(false) expect(pendingUserAction(manager.displayMessages)).toBe(undefined) - // No turn is left to save it: unpersisted, the next load restores it pending. - expect(saveChat).toHaveBeenCalledOnce() - expect(saveChat.mock.calls[0][0][0].runForm?.canceled).toBe(true) + }) + + // A save that fires mid-turn (jobs tray, review dock) stores a transcript nothing + // will resume. Storing a card still pending brings back a form whose Run resolves + // no callback. + it('stores loading cards settled when a mid-turn save fires', async () => { + const manager = new AIChatManager() + manager.displayMessages = [ + { + role: 'tool', + tool_call_id: 'call_r', + content: 'Waiting for you to confirm the arguments of "f/a/b"', + isLoading: true, + runForm: { path: 'f/a/b', schema: {}, args: {} } + } + ] + const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined) + + manager.markJobsReviewed([]) + manager.dismissJob('nope') + await Promise.resolve() + + const stored = saveChat.mock.calls.at(-1)?.[0]?.[0] + expect(stored?.isLoading).toBe(false) + expect(stored?.runForm?.canceled).toBe(true) + // The live card is untouched — the turn is still parked on it. + expect(manager.displayMessages[0].isLoading).toBe(true) }) // Stop ends the turn, not the job: the deployed script is already running with all diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 890097b166..3a9017c9d7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -909,7 +909,7 @@ const runScriptSchema = z.object({ const runScriptToolDef = createToolDef( runScriptSchema, 'run_script', - "Run a DEPLOYED script for real, under the user's own permissions. The user always gets an argument form prefilled with `args` and decides what actually runs, so fill in every argument you can infer rather than leaving the form empty. Use this when the user asks to run or execute something; use test_run_script instead to try out a script you are writing.", + "Run a DEPLOYED script for real, under the user's own permissions. The user always gets an argument form prefilled with `args` and decides what actually runs, so fill in every argument you can infer rather than leaving the form empty. Call it even when you cannot supply them all: the form is where the user enters what you cannot, including required files and secrets, so open it rather than asking for those in chat. Use this when the user asks to run or execute something; use test_run_script instead to try out a script you are writing.", { strict: false } )