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) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-03 16:30:09 +02:00
co-authored by Claude Opus 5
parent 03de525cc0
commit a4f500e0ab
3 changed files with 37 additions and 12 deletions
@@ -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()
@@ -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
@@ -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 }
)