mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 08:01:38 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user