perf: drop the deployed schema from a card that stopped showing a form

This commit is contained in:
AlexRV12
2026-09-03 16:30:09 +02:00
parent 0ded5f96f2
commit 9ee48405a5
4 changed files with 37 additions and 10 deletions
@@ -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<RunFormDisplay>) => {
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
}
}
@@ -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.
@@ -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<string, any>)
let schema = $state($state.snapshot(runForm.schema) as Record<string, any>)
let schema = $state($state.snapshot(runForm.schema ?? {}) as Record<string, any>)
const properties = $derived(schema?.properties ?? {})
const hasArgs = $derived(Object.keys(properties).length > 0)
@@ -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<string, any>
/** 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<string, any>
/** Prefill only: the card's `parameters` records what the job started with. */
args: Record<string, any>
/** Proposed arguments the schema does not declare, so they have no field. Named on