fix: keep ai chat messages when leaving the page mid-generation (#10809)

* fix: persist ai chat turns mid-generation so leaving the page keeps them

* fix: stop chat checkpoints once the turn commits, keep streamed text visible

* fix: checkpoint streamed answers as they grow and keep half-run tool batches

* fix: checkpoint text as received so a backgrounded tab keeps capturing

* fix: keep buffered tool screenshots in mid-batch chat checkpoints

* fix: decide committed-text at the flush site, condense checkpoint comments

* fix: checkpoint only live streamed text, never text the parser owns

* fix: don't swap the chat transcript out from under a running turn

* fix: close the pre-loading window in the conversation-switch guard
This commit is contained in:
hugocasa
2026-08-24 22:30:45 +02:00
committed by GitHub
parent 8dbd12ecc1
commit 541b6c8496
8 changed files with 635 additions and 26 deletions
@@ -611,7 +611,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div class="flex flex-col">
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1"
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading || aiChatManager.sendInFlight}
title={aiChatManager.loading || aiChatManager.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
loadPastChat(chat.id)
close()
@@ -29,6 +29,7 @@ import {
completedJobToolStatus,
backgroundJobCompletionNote,
deriveChatJobStatus,
pendingToolImagesMessage,
trimJob
} from './shared'
import type {
@@ -99,7 +100,7 @@ import {
import type { Selection } from 'monaco-editor'
import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
import { sanitizeToolCallArguments } from './toolCallArguments'
import {
billedTokens,
@@ -164,6 +165,15 @@ function prefersInstantReveal(): boolean {
// schema changes from mode switches, and the estimate's chars/4 error.
const COMPACTION_TRIGGER_RATIO = 0.8
const COMPACTION_TARGET_RATIO = 0.7
// How often a running turn is offered to the mid-turn checkpoint (see
// sendRequest). The whole transcript is rewritten on each accepted checkpoint,
// so this bounds the write rate; it also bounds how much of a turn a tab that
// dies without warning can lose.
const CHECKPOINT_INTERVAL_MS = 2000
// Stands in for the result of a tool call that had not finished when the
// transcript was checkpointed — still running, or still waiting to be confirmed.
// The model reads the step as unfinished, which is what the card tells the reader.
const INTERRUPTED_TOOL_RESULT = 'Interrupted: the chat was closed before this tool finished'
// Flat per-image token estimate for a downscaled (≤1568px) vision image. Used instead
// of chars/4 on the base64 data URL, which would overcount by ~50x.
const IMAGE_TOKEN_ESTIMATE = 1200
@@ -2296,27 +2306,63 @@ export class AIChatManager {
}
}
// Commit an interrupted turn's usable output as context for a follow-up:
// the tool-paired prefix of completed steps (a dangling tool call would
// make providers reject the next request) plus the partial answer text.
// A reasoning-only interrupt instead drops its stuck-open bubble.
private commitInterruptedTurn = (
// The transcript an interrupted turn leaves behind: the stored history, the
// tool-paired prefix of the turn's completed steps (a dangling tool call
// would make providers reject the next request), and the partial answer text
// when it isn't already inside that prefix. Pure — the caller decides whether
// this becomes the live transcript or only a persisted checkpoint.
private interruptedTurnMessages = (
collectedMessages: ChatCompletionMessageParam[],
partialReply: string
) => {
const prefix = truncateToToolPairedPrefix(collectedMessages)
this.messages = [...this.messages, ...prefix]
partialReply: string,
// Passed only by the mid-turn checkpoint, whose result must outlive its turn.
// A turn committed for a follow-up is still live, so it would rather truncate
// a half-run batch and rerun it than read results nothing produced.
snapshot?: {
/** Result to synthesize for the calls of a batch caught mid-execution. */
interruptedToolContent: string
/** Images a tool has produced that the turn has not yet turned into a
* message (see appendPendingToolImages). */
bufferedImages?: ChatCompletionMessageParam
}
): { messages: ChatCompletionMessageParam[]; keptPartialReply: boolean } => {
const prefix = snapshot
? closeInterruptedToolBatch(collectedMessages, snapshot.interruptedToolContent)
: truncateToToolPairedPrefix(collectedMessages)
// partialReply can be stale — equal to text already committed inside the
// prefix (see its capture in onMessageEnd) — so only append when new.
// prefix — so only append when new. A snapshot is exempt: it passes only
// live streaming text, which is never in the prefix, and identical text can
// legitimately recur across iterations where content alone cannot judge it.
const lastCommittedText = [...prefix]
.reverse()
.find(
(m): m is ChatCompletionMessageParam & { content: string } =>
m.role === 'assistant' && typeof m.content === 'string' && !!m.content.trim()
)?.content
if (partialReply.trim() && partialReply !== lastCommittedText) {
this.messages = [...this.messages, { role: 'assistant', content: partialReply }]
} else {
const keptPartialReply =
!!partialReply.trim() && (!!snapshot || partialReply !== lastCommittedText)
// Images sit between the batch that produced them and whatever the model
// said next, matching where appendPendingToolImages puts them live.
const tail = snapshot?.bufferedImages ? [...prefix, snapshot.bufferedImages] : prefix
return {
messages: keptPartialReply
? [...this.messages, ...tail, { role: 'assistant', content: partialReply }]
: [...this.messages, ...tail],
keptPartialReply
}
}
// Commit an interrupted turn's usable output as context for a follow-up.
// A reasoning-only interrupt instead drops its stuck-open bubble.
private commitInterruptedTurn = (
collectedMessages: ChatCompletionMessageParam[],
partialReply: string
) => {
const { messages, keptPartialReply } = this.interruptedTurnMessages(
collectedMessages,
partialReply
)
this.messages = messages
if (!keptPartialReply) {
const last = this.displayMessages[this.displayMessages.length - 1]
if (last?.role === 'assistant' && !last.content.trim() && !!last.reasoning) {
this.displayMessages = this.displayMessages.slice(0, -1)
@@ -3036,6 +3082,83 @@ export class AIChatManager {
// auto-sends the next queued message. Cancel, error, and empty-response
// rollbacks leave it false so queued text is restored to the input.
let turnCommittedCleanly = false
// A turn's output only reaches history when the turn ends, so a tab closed
// mid-turn loses every step it had taken. Persist progress WITHOUT
// committing it: the outcome branches still need `this.messages`
// unmodified to roll the turn back, and their save overwrites this.
let checkpointedShape = ''
const checkpointTurn = async (force = false) => {
// Text as received, not as painted: a hidden tab pauses the reveal loop
// while text keeps arriving, so fingerprinting painted text would stall
// the poll exactly when nobody is watching. `pending` reads it without
// disturbing the animation.
const streaming = this.currentReply + this.replyReveal.pending
// Write only when the turn advanced, so a parked confirmation costs
// nothing and the rate follows steps taken rather than time.
const shape = `${collectedMessages.length}:${this.displayMessages.length}:${streaming.length}`
if (!force && shape === checkpointedShape) return
// Live text only. Text the parsers have flushed is theirs to push, and
// they do so before the tool execution a checkpoint is likely to land in
// — so reading it here would mean re-appending what the transcript
// already holds. The abort path still recovers it via partialReply.
const { messages, keptPartialReply } = this.interruptedTurnMessages(
collectedMessages,
streaming,
{
interruptedToolContent: INTERRUPTED_TOOL_RESULT,
// A screenshot's image becomes a message only once its whole batch
// does, so a batch closed mid-flight would restore a result
// announcing a screenshot the model cannot see. Read without
// draining: the live turn still owns the buffer.
bufferedImages: pendingToolImagesMessage([...this.pendingToolImages.values()].flat())
}
)
if (messages.length === this.messages.length) return
checkpointedShape = shape
const display = this.settledToolDisplay(this.displayMessages, 'Interrupted')
// onMessageEnd is what gives streamed text its bubble, and it clears
// currentReply doing so — text still there has none, and without one the
// reply returns as context the reader cannot see.
const withStreamed =
streaming && keptPartialReply
? [...display, { role: 'assistant' as const, content: streaming }]
: display
// Best-effort: the turn-end save is the authoritative one, so a failed
// checkpoint must never break the turn it is only shadowing.
try {
await this.historyManager.saveChat(
withStreamed,
messages,
// No report describes this transcript: `contextUsage` still measures
// the pre-turn history while these messages already carry part of the
// turn. Storing it would under-report a restored chat by the whole
// partial turn — enough to skip the compaction its next send needs.
// Omitting drops the field, which is the "readers estimate" fallback.
undefined,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
} catch (e) {
console.error('Failed to checkpoint chat mid-turn', e)
}
}
const checkpointTimer = setInterval(() => void checkpointTurn(), CHECKPOINT_INTERVAL_MS)
// `hidden` precedes pagehide on close, reload and navigation and still runs a
// live document, so it is the last point a write can land. A navigation can
// outrun it — the poll, not this, carries the guarantee. `document` is absent
// under SSR and the node test env.
const hideTarget = typeof document !== 'undefined' ? document : undefined
const checkpointOnHide = () => {
if (hideTarget?.visibilityState === 'hidden') void checkpointTurn(true)
}
hideTarget?.addEventListener('visibilitychange', checkpointOnHide)
// Must stop when the loop hands back, not in `finally`: the outcome branches
// merge the turn into `this.messages` before awaiting their save, so a
// checkpoint there would re-append the same messages and, queued behind that
// save, persist the duplicate. Idempotent — every exit path calls it.
const stopCheckpoints = () => {
clearInterval(checkpointTimer)
hideTarget?.removeEventListener('visibilitychange', checkpointOnHide)
}
try {
// A queued message carries its own context snapshot (contextOverride); use
// it verbatim and leave the live selection alone (it belongs to whatever the
@@ -3407,6 +3530,7 @@ export class AIChatManager {
webSearchUnavailable = true
}
})
stopCheckpoints()
const wasAborted = this.abortController?.signal.aborted ?? false
// Pure reasoning doesn't count as usable: it's not replayed as context,
// so a reasoning-only turn is as unsent as a literally empty one.
@@ -3518,6 +3642,7 @@ export class AIChatManager {
}
}
} catch (err) {
stopCheckpoints()
console.error(err)
// Request failure: keep the usable output as context for a follow-up.
// Skipped when the throw came from post-outcome code (e.g. saveChat) —
@@ -3576,6 +3701,9 @@ export class AIChatManager {
sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true)
} finally {
this.loading = false
// Backstop for the paths that leave the try without reaching either call
// above (a pre-flight throw, an aborted compaction).
stopCheckpoints()
// Turn teardown: cancel any in-flight reveal frame and drop leftover
// backlog. onMessageEnd already flushed on every outcome, so this only
// releases the loop; it never discards uncommitted text.
@@ -3802,6 +3930,11 @@ export class AIChatManager {
}
loadPastChat = async (id: string) => {
// A turn commits into whatever transcript it finds when it ends, so swapping
// one in underneath it misfiles the turn — or duplicates it, when the loaded
// chat already carries the turn's own checkpoint. Gated on `sendInFlight`
// too, for the pre-`loading` window `sendOrQueue` documents.
if (this.loading || this.sendInFlight) return
const chat = await this.historyManager.loadPastChat(id)
if (chat) {
// Drop any message queued in the current conversation so it doesn't
@@ -4155,13 +4288,26 @@ export class AIChatManager {
}
}
cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => {
this.displayMessages = this.displayMessages.map((message) => {
// In-flight and queued tool cards settled into a terminal state. Persisting
// one as-is restores a card that spins forever, and an unanswered question
// keeps the composer disabled (see isActiveUserQuestion) with nothing left
// running to answer it — so every transcript that outlives its turn goes
// through here first.
private settledToolDisplay = (
messages: DisplayMessage[],
messageText: string
): DisplayMessage[] =>
messages.map((message) => {
if (message.role === 'tool' && (message.isLoading || message.isQueued)) {
return {
...message,
isLoading: false,
isQueued: false,
// Both render live affordances on their own, without consulting
// isLoading: a Run/Reject footer for a call nothing is waiting on,
// and a card that hides its result as still-streaming.
needsConfirmation: false,
isStreamingArguments: false,
// A question's card disappears once canceled, so keep the question
// itself readable in the collapsed header.
content: message.userQuestion
@@ -4175,6 +4321,9 @@ export class AIChatManager {
}
return message
})
cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => {
this.displayMessages = this.settledToolDisplay(this.displayMessages, messageText)
}
}
@@ -4,6 +4,7 @@ import type { PipelineAIChatHelpers } from './pipeline/core'
import type { CurrentEditor } from '$lib/components/flows/types'
import type { ReviewChangesOpts } from './monaco-adapter'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import type { DisplayMessage } from './shared'
import type { AttachedImage } from './imageUtils'
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { chatState } from './sharedChatState.svelte'
@@ -1800,6 +1801,312 @@ describe('AIChatManager queued messages', () => {
expect(input.restoreInstructions).not.toHaveBeenCalled()
})
// This suite runs under the node env, so stand up the minimum document the
// manager needs to hear the page going away. Returns the registered listeners
// so a test can play the user leaving.
function stubHidingPage() {
const leavePage = new Set<() => void>()
vi.stubGlobal('document', {
visibilityState: 'hidden',
addEventListener: (_: string, fn: () => void) => leavePage.add(fn),
removeEventListener: (_: string, fn: () => void) => leavePage.delete(fn)
})
return leavePage
}
// Every checkpoint test installs a fake document; leaking one would make a
// single failure cascade through every later test in the file.
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('keeps re-checkpointing a streamed answer as it grows', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const manager = createManager()
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
// One poll interval, per CHECKPOINT_INTERVAL_MS in AIChatManager.
const pastOnePoll = 2100
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
// A text-only answer: nothing lands in addedMessages and no card appears,
// so the growing reply is the only thing that can drive the poll.
for (const text of ['the first part', 'the first part and more', 'the whole answer']) {
manager.currentReply = text
await vi.advanceTimersByTimeAsync(pastOnePoll)
}
const message = { role: 'assistant' as const, content: manager.currentReply }
config.addedMessages.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'write me something long' })
// A fingerprint blind to the reply would freeze at whatever the first tick
// captured, so each tick must persist strictly more of the answer.
const persisted = saveChat.mock.calls
.map(([, messages]) => messages as ChatCompletionMessageParam[])
.map((messages) => messages[messages.length - 1])
.filter((m) => m?.role === 'assistant' && typeof m.content === 'string')
.map((m) => String(m.content))
.filter((c) => c.startsWith('the first part'))
expect(persisted).toEqual(['the first part', 'the first part and more'])
})
it('carries a completed screenshot into a checkpoint of the batch it came from', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
// take_screenshot finished and buffered its image, but the batch it belongs
// to has another call still pending — so the loop has not yet turned the
// buffer into a message.
config.addedMessages.push(
{
role: 'assistant' as const,
content: '',
tool_calls: [
{
id: 'shot',
type: 'function' as const,
function: { name: 'take_screenshot', arguments: '{}' }
},
{
id: 'next',
type: 'function' as const,
function: { name: 'do_thing', arguments: '{}' }
}
]
},
{ role: 'tool' as const, tool_call_id: 'shot', content: 'Screenshot attached below' }
)
config.callbacks.attachToolImage('shot', {
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
name: 'shot.png'
})
leavePage.forEach((fn) => fn())
return {
addedMessages: config.addedMessages,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'look at the app' })
const [, actual] = saveChat.mock.calls.find(
([, messages]) => messages.length > 1
) as unknown as [DisplayMessage[], ChatCompletionMessageParam[]]
// Without the image the restored history announces a screenshot the model
// cannot see, so the next turn cannot answer anything about it.
const imageParts = actual.flatMap((m) =>
Array.isArray(m.content) ? m.content.filter((p: any) => p.type === 'image_url') : []
)
expect(imageParts).toHaveLength(1)
// And it sits after the batch that produced it, where the live path puts it.
const imageIdx = actual.findIndex((m) => Array.isArray(m.content))
const resultIdx = actual.findIndex((m) => m.role === 'tool' && m.tool_call_id === 'shot')
expect(imageIdx).toBeGreaterThan(resultIdx)
})
it('does not repeat a preamble the parser has already pushed', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
const preamble = 'Let me look that up.'
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
// A text-then-tool-call turn in parser order: the preamble is flushed when
// the tool call starts, pushed when the message completes, and only then
// do the tools run — the long window a checkpoint is most likely to land in.
manager.currentReply = preamble
config.callbacks.onMessageEnd()
config.addedMessages.push(
{ role: 'assistant' as const, content: preamble },
{
role: 'assistant' as const,
content: '',
tool_calls: [
{ id: 't1', type: 'function' as const, function: { name: 'do_thing', arguments: '{}' } }
]
},
{ role: 'tool' as const, tool_call_id: 't1', content: 'ok' }
)
leavePage.forEach((fn) => fn())
return {
addedMessages: config.addedMessages,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'look something up' })
const [, actual] = saveChat.mock.calls.find(
([, messages]) => messages.length > 1
) as unknown as [DisplayMessage[], ChatCompletionMessageParam[]]
// Reading flushed text back would show the preamble twice on reload; the
// transcript already holds it, so the checkpoint must take it from there.
expect(actual.filter((m) => m.content === preamble)).toHaveLength(1)
})
it('checkpoints a live reply that repeats an earlier segment verbatim', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
const repeated = 'Let me check that.'
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
// The model said the same sentence before its tool call as it is saying
// after it — the staleness heuristic must not read the second one as a
// duplicate of the first and drop it.
config.addedMessages.push(
{
role: 'assistant' as const,
content: repeated,
tool_calls: [
{ id: 't1', type: 'function' as const, function: { name: 'do_thing', arguments: '{}' } }
]
},
{ role: 'tool' as const, tool_call_id: 't1', content: 'ok' }
)
manager.currentReply = repeated
leavePage.forEach((fn) => fn())
return {
addedMessages: config.addedMessages,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'check the thing' })
const [display, actual] = saveChat.mock.calls.find(
([, messages]) => messages.length > 1
) as unknown as [DisplayMessage[], ChatCompletionMessageParam[]]
expect(actual[actual.length - 1]).toMatchObject({ role: 'assistant', content: repeated })
expect(display[display.length - 1]).toMatchObject({ role: 'assistant', content: repeated })
})
it('checkpoints a turn to history when the page is hidden mid-generation', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
const toolCall = (id: string, name: string) => ({
role: 'assistant' as const,
content: '',
tool_calls: [{ id, type: 'function' as const, function: { name, arguments: '{}' } }]
})
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
// One completed round-trip, then a call still waiting on the user.
config.addedMessages.push(
toolCall('t1', 'write_script'),
{ role: 'tool', tool_call_id: 't1', content: 'created' },
toolCall('t2', 'test_run_script')
)
config.callbacks.setToolStatus('t2', {
content: 'Waiting for confirmation...',
isLoading: true,
needsConfirmation: true
})
leavePage.forEach((fn) => fn())
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages.push({ role: 'tool', tool_call_id: 't2', content: 'ran' }, message)
return {
addedMessages: config.addedMessages,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'write and run a script' })
const [display, actual] = saveChat.mock.calls.find(
([, messages]) => messages.length > 1
) as unknown as [DisplayMessage[], ChatCompletionMessageParam[]]
// Both steps are kept, and the unfinished t2 call gets a synthesized result:
// leaving it dangling would make the next request 400, dropping it would lose
// the step the reader can still see on the card below.
expect(actual.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool'])
expect(actual[actual.length - 1]).toMatchObject({
tool_call_id: 't2',
content: expect.stringContaining('Interrupted')
})
// Its card is kept but settled, so reopening the chat doesn't restore a
// confirmation prompt with nothing behind it.
expect(display.find((m) => m.role === 'tool' && m.tool_call_id === 't2')).toMatchObject({
isLoading: false,
needsConfirmation: false,
error: 'Interrupted'
})
// The turn itself is untouched by the checkpoint and still commits in full.
expect(manager.messages.map((m) => m.role)).toEqual([
'user',
'assistant',
'tool',
'assistant',
'tool',
'assistant'
])
})
it('stops checkpointing once the turn commits, so the transcript is never doubled', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
// The turn-end save is where the race lives: the outcome branch has already
// merged the turn into `manager.messages` and is awaiting this call, so a
// checkpoint landing here would append the same messages a second time.
let leaveDuringFinalSave: () => void = () => {}
const saveChat = vi
.spyOn(manager.historyManager, 'saveChat')
.mockImplementation(async () => leaveDuringFinalSave())
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
const collected = [
{
role: 'assistant' as const,
content: '',
tool_calls: [
{
id: 't1',
type: 'function' as const,
function: { name: 'write_script', arguments: '{}' }
}
]
},
{ role: 'tool' as const, tool_call_id: 't1', content: 'created' },
{ role: 'assistant' as const, content: 'done' }
]
config.addedMessages.push(...collected)
leaveDuringFinalSave = () => leavePage.forEach((fn) => fn())
return {
addedMessages: collected,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'write a script' })
await Promise.resolve()
// A duplicated transcript repeats t1, which providers reject outright — so
// no persisted call may carry the same tool_call_id twice.
for (const [, messages] of saveChat.mock.calls as unknown as [
unknown,
ChatCompletionMessageParam[]
][]) {
const toolCallIds = messages.flatMap((m: any) => m.tool_calls?.map((c: any) => c.id) ?? [])
expect(toolCallIds).toEqual([...new Set(toolCallIds)])
}
expect(manager.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant'])
})
it('restores consumed DOM selector chips when a turn is cancelled before output', async () => {
const manager = createManager(createInputMock())
manager.mode = AIMode.GLOBAL
@@ -1966,6 +2273,61 @@ describe('AIChatManager queued messages', () => {
expect(manager.queuedMessage).toBe('')
})
it('refuses to switch conversation while a turn is running', async () => {
const manager = createManager(createInputMock())
const loadStored = vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({
id: 'chat-b',
title: 'Chat B',
displayMessages: [{ role: 'user', content: 'belongs to chat B', index: 0 }],
actualMessages: [{ role: 'user', content: 'belongs to chat B' }],
lastModified: 0
} as unknown as ReturnType<typeof manager.historyManager.loadPastChat>)
vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
// The user opens History mid-turn. Swapping the transcript in underneath
// the turn makes the commit below land on a foreign one — and when the
// loaded chat is this one, on top of its own checkpoint.
await manager.loadPastChat('chat-b')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'belongs to chat A' })
expect(loadStored).not.toHaveBeenCalled()
expect(manager.messages.map((m) => m.content)).toEqual(['belongs to chat A', 'done'])
})
it('refuses to switch conversation before `loading` has risen', async () => {
const manager = createManager(createInputMock())
const loadStored = vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({
id: 'chat-b',
title: 'Chat B',
displayMessages: [{ role: 'user', content: 'belongs to chat B', index: 0 }],
actualMessages: [{ role: 'user', content: 'belongs to chat B' }],
lastModified: 0
} as unknown as ReturnType<typeof manager.historyManager.loadPastChat>)
vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
replyWith('done')
const sent = manager.sendRequest({ instructions: 'belongs to chat A' })
// The send is registered but its attachment upkeep hasn't finished, so
// `loading` is still false — the window `sendOrQueue` also guards.
expect(manager.loading).toBe(false)
expect(manager.sendInFlight).toBe(true)
await manager.loadPastChat('chat-b')
await sent
expect(loadStored).not.toHaveBeenCalled()
expect(manager.messages.map((m) => m.content)).toEqual(['belongs to chat A', 'done'])
})
it('clears attachments on New chat / load past chat (non-session), keeps them in a session', async () => {
const txt = (n: string) => new File(['hello\n'], n, { type: 'text/plain' })
@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from '$lib/utils/uuid'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import { runChatLoop, truncateToToolPairedPrefix, type ChatLoopConfig } from './chatLoop'
import {
closeInterruptedToolBatch,
runChatLoop,
truncateToToolPairedPrefix,
type ChatLoopConfig
} from './chatLoop'
import type { ReasoningProviderModel } from '../reasoningRegistry'
const mocks = vi.hoisted(() => ({
@@ -553,6 +558,25 @@ const tool = (id: string): ChatCompletionMessageParam => ({
})
const user = (content: string): ChatCompletionMessageParam => ({ role: 'user', content })
describe('closeInterruptedToolBatch', () => {
it('keeps the answered half of a batch still executing, pairing the rest', () => {
// The model asked for two tools in one message; the first wrote a script
// (a real side effect) and the second is still going. Truncating would drop
// both, so the restored chat would not know the script exists.
const msgs = [assistantTools('a', 'b'), tool('a')]
expect(closeInterruptedToolBatch(msgs, 'stopped')).toEqual([
assistantTools('a', 'b'),
tool('a'),
{ role: 'tool', tool_call_id: 'b', content: 'stopped' }
])
})
it('leaves an already-paired transcript alone', () => {
const msgs = [assistantTools('a'), tool('a'), assistant('done')]
expect(closeInterruptedToolBatch(msgs, 'stopped')).toEqual(msgs)
})
})
describe('truncateToToolPairedPrefix', () => {
it('returns an empty array unchanged', () => {
expect(truncateToToolPairedPrefix([])).toEqual([])
@@ -129,6 +129,38 @@ export function truncateToToolPairedPrefix(
return messages.slice(0, lastValidLen)
}
/**
* Like `truncateToToolPairedPrefix`, but closes the batch it would have dropped:
* every call in the trailing `tool_calls` message still missing a result gets one
* saying it was interrupted. A batch's calls execute one at a time, so truncating
* it discards steps that already finished — and whose side effects are already
* real. For a snapshot that must outlive its turn, keeping them beats a clean cut.
*/
export function closeInterruptedToolBatch(
messages: ChatCompletionMessageParam[],
interruptedContent: string
): ChatCompletionMessageParam[] {
const paired = truncateToToolPairedPrefix(messages)
const rest = messages.slice(paired.length)
const batch = rest[0]
if (batch?.role !== 'assistant' || !batch.tool_calls?.length) return paired
// Results for THIS batch only, stopping at the first non-tool message:
// anything past a still-pending batch was never valid context.
const batchIds = new Set(batch.tool_calls.map((c) => c.id))
const answers: ChatCompletionMessageParam[] = []
const answered = new Set<string>()
for (const m of rest.slice(1)) {
if (m.role !== 'tool') break
if (!batchIds.has(m.tool_call_id)) continue
answers.push(m)
answered.add(m.tool_call_id)
}
const interrupted = [...batchIds]
.filter((id) => !answered.has(id))
.map((id) => ({ role: 'tool' as const, tool_call_id: id, content: interruptedContent }))
return [...paired, batch, ...answers, ...interrupted]
}
const unsupportedWebSearchCache = new Set<string>()
const WEB_SEARCH_UNAVAILABLE_STATUS_CODES = new Set([400, 403, 404])
@@ -1024,20 +1024,29 @@ export async function processToolCall<T>({
* id is already answered by its tool result before this non-tool message. The image
* parts ride the same `image_url` carrier that the provider converters translate.
*/
export function appendPendingToolImages(
messages: ChatCompletionMessageParam[],
addedMessages: ChatCompletionMessageParam[],
toolCallbacks: ToolCallbacks
): void {
const images = toolCallbacks.takePendingToolImages?.() ?? []
if (images.length === 0) return
const message: ChatCompletionMessageParam = {
/** The message that hands tool-produced images to the model, or undefined when
* there are none. Split out so a snapshot of a turn still buffering them can
* build the same message without draining the buffer the live turn still owns. */
export function pendingToolImagesMessage(
images: AttachedImage[]
): ChatCompletionMessageParam | undefined {
if (images.length === 0) return undefined
return {
role: 'user',
content: [
{ type: 'text', text: 'Screenshot(s) of the app preview:' },
...images.map((img) => dataUrlToImagePart(img.dataUrl))
]
}
}
export function appendPendingToolImages(
messages: ChatCompletionMessageParam[],
addedMessages: ChatCompletionMessageParam[],
toolCallbacks: ToolCallbacks
): void {
const message = pendingToolImagesMessage(toolCallbacks.takePendingToolImages?.() ?? [])
if (!message) return
messages.push(message)
addedMessages.push(message)
}
@@ -73,6 +73,26 @@ describe('TypewriterReveal', () => {
expect(revealed().length).toBeLessThan(text.length)
})
it('exposes un-revealed text without advancing or ending the reveal', () => {
const sched = new FakeScheduler()
const { reveal, revealed } = makeReveal(sched)
const text = 'x'.repeat(300)
reveal.push(text)
sched.frame(16)
// A hidden tab stops scheduling frames while text keeps arriving, so a
// caller that must account for everything received reads `pending` — and
// reading it must not paint or stop the animation the way flush() does.
expect(revealed() + reveal.pending).toBe(text)
expect(reveal.pending.length).toBeGreaterThan(0)
const paintedBefore = revealed()
expect(reveal.pending).toBe(text.slice(paintedBefore.length))
expect(revealed()).toBe(paintedBefore)
// Still animating: further frames keep painting, which flush() would have
// prevented by ending the reveal.
sched.frames(3, 34)
expect(revealed().length).toBeGreaterThan(paintedBefore.length)
})
it('preserves text exactly after flush (no loss, no duplication)', () => {
const sched = new FakeScheduler()
const { reveal, revealed } = makeReveal(sched)
@@ -87,6 +87,15 @@ export class TypewriterReveal {
this.ensureRunning()
}
/** Text that has arrived but has not been revealed yet. Lets a caller that must
* account for everything the model has sent — not just what has been painted —
* read it without disturbing the pacing. `flush()` is the wrong tool there: it
* ends the animation. The gap it covers is unbounded, because a hidden tab
* pauses the paint loop while text keeps arriving. */
get pending(): string {
return this.buffer.slice(this.revealed)
}
/** Reveal everything still buffered now and stop. Call before reading the
* owner's reactive state into committed state, so the read sees the full text. */
flush(): void {