fix: settle no card the poller will resolve after a reload

This commit is contained in:
AlexRV12
2026-09-03 16:30:09 +02:00
parent 50dc476652
commit 0ded5f96f2
2 changed files with 30 additions and 8 deletions
@@ -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)
)
}
}
@@ -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)