diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 126a975f69..ce69ddbdf7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -190,6 +190,11 @@ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3 // (panel teardown, save-and-clear) pass their own reason, so the queued-message // flush can tell "the user wants to move on" from "the turn was torn down". const USER_CANCEL_REASON = 'user_cancelled' +// Applied wherever a run form stops rendering. Only the form reads the deployed schema, +// so past that point it is a copy of the script's declarations — password and file +// defaults with them — persisted for the life of the chat. +const settledRunForm = (runForm: RunFormDisplay): RunFormDisplay => + runForm.submitted || runForm.canceled ? { ...runForm, schema: undefined } : runForm // Built-in `/compact` session command — summarizes the conversation locally // instead of sending a turn to the model. Matched on the whole input so a // regular message that merely mentions "/compact" mid-sentence is unaffected. @@ -1840,7 +1845,7 @@ export class AIChatManager { isLoading: false, error: 'Cancelled by user', content: `Run of "${message.runForm.path}" cancelled by user`, - runForm: { ...message.runForm, canceled: true } + runForm: settledRunForm({ ...message.runForm, canceled: true }) } : message ) @@ -1854,7 +1859,7 @@ export class AIChatManager { #patchRunForm = (toolId: string, patch: Partial) => { this.displayMessages = this.displayMessages.map((message) => message.role === 'tool' && message.tool_call_id === toolId && message.runForm - ? { ...message, runForm: { ...message.runForm, ...patch } } + ? { ...message, runForm: settledRunForm({ ...message.runForm, ...patch }) } : message ) } @@ -4643,7 +4648,7 @@ export class AIChatManager { ? { ...message.userQuestion, canceled: true } : undefined, runForm: message.runForm - ? { ...message.runForm, canceled: runState === 'idle' } + ? settledRunForm({ ...message.runForm, canceled: runState === 'idle' }) : undefined } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 20169b2de5..b6fb980c0a 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -261,6 +261,25 @@ describe('AIChatManager run form', () => { expect(manager.displayMessages[0].isLoading).toBe(true) }) + // Only the rendered form reads the schema, and a settled card renders none. Kept, it + // would sit in history for the life of the chat with the script's own password and + // file defaults inside it. + it('drops the schema from a card that has stopped showing a form', () => { + const manager = new AIChatManager() + const runForm = { path: 'f/a/b', schema: { properties: { tok: { password: true } } }, args: {} } + manager.displayMessages = [ + { role: 'tool', tool_call_id: 'call_r', content: '', isLoading: true, runForm } + ] + + manager.handleRunFormCancel('call_r') + + expect(manager.displayMessages[0].runForm?.schema).toBeUndefined() + expect(manager.displayMessages[0].runForm?.canceled).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. diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte index f3ccd96d84..519815dd59 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -27,12 +27,12 @@ const workspace = $derived(aiChatManager.operatingWorkspace) // Deep copies, not spreads: runForm comes off displayMessages ($state), so its nested - // values are proxies that $state() hands back untouched. SchemaForm edits objects and - // arrays in place, so a shallow copy would write every keystroke — a password typed - // into a nested field included — straight into the persisted transcript. The schema - // goes the same way: SchemaForm binds it and reorders its properties on mount. + // values are proxies that $state() hands back untouched. SchemaForm edits both in + // place — and binds the schema, reordering its properties on mount — so a shallow + // copy would write every keystroke, a password typed into a nested field included, + // straight into the persisted transcript. let args = $state($state.snapshot(runForm.args ?? {}) as Record) - let schema = $state($state.snapshot(runForm.schema) as Record) + let schema = $state($state.snapshot(runForm.schema ?? {}) as Record) const properties = $derived(schema?.properties ?? {}) const hasArgs = $derived(Object.keys(properties).length > 0) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 625e2a4d77..cb2915fc7f 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -558,8 +558,11 @@ export function answeredChoices(q: UserQuestionDisplay): string[] | undefined { export type RunFormDisplay = { path: string summary?: string - /** Of the DEPLOYED script, not a draft. */ - schema: Record + /** Of the DEPLOYED script, not a draft. Only the rendered form reads it, so it is + * dropped once one of the flags below unmounts that form: kept, every settled card + * would carry a copy of the schema — password and file defaults included — in + * history forever. */ + schema?: Record /** Prefill only: the card's `parameters` records what the job started with. */ args: Record /** Proposed arguments the schema does not declare, so they have no field. Named on