fix(daemon): restore full assistant turn after mid-flight reload reattach (#2383)

* fix(daemon): restore full assistant turn after mid-flight reload reattach

When a daemon run is in progress and the browser reloads, the client
reattaches and the artifact recovers, but the restored chat turn drops
assistant text, thinking events, and producedFiles. Three independent
defects combine to cause this:

1. The reattach onDone never populated producedFiles. The pre-turn file
   snapshot used as the diff baseline lived only in a closure. Now it is
   persisted on the assistant message as preTurnFileNames so the reattach
   path can rebuild the diff after reload.

2. The SSE replay used a strict `>` cursor compare. A client that had
   already persisted lastRunEventId equal to the final event id received
   zero replay events on terminal-run reattach, fell into the status-only
   REST fallback, and never fired a clean onDone. The server now replays
   the final buffered event on terminal-run reattach when the cursor is
   at or past the end, so the client always sees a terminal signal.

3. The text buffer flushed on visibilitychange but not on pagehide.
   Hard reloads on browsers where visibilitychange does not fire before
   teardown could lose the last ~250ms of streamed text from the
   persisted message. A pagehide listener now flushes synchronously.

Refactor: extracted computeProducedFiles helper so the send and reattach
flows share the diff logic and cannot drift apart again.

Tests:
- apps/web/tests/components/ProjectView.reattach-restore.test.tsx
  covers: reattach onDone populates producedFiles from preTurnFileNames;
  reattach reaches succeeded via SSE even when only the end event replays;
  computeProducedFiles unit cases.
- apps/daemon/tests/runs.test.ts adds replay-cursor coverage for both
  the terminal-replay safety branch and the no-duplicate normal branch.

* fix(daemon): persist preTurnFileNames end-to-end on the messages table

Review on #2383 caught that `ChatMessage.preTurnFileNames` (added in
packages/contracts) had no daemon-side persistence: the messages
schema, upsertMessage, and normalizeMessage all ignored the field.
saveMessage() would PUT the field, the daemon would silently drop it,
and a real page reload would read a row without `preTurnFileNames`, so
the reattach onDone fell back to `new Set(nextFiles.map(...))` and
still missed files produced earlier in the turn.

This commit closes the round trip:

- New `pre_turn_file_names_json TEXT` column on the messages table,
  with a forward-compatible ALTER for existing databases (same pattern
  as agent_id / feedback_json / run_status).
- Both upsertMessage branches (UPDATE and INSERT) now serialize
  m.preTurnFileNames into the new column.
- listMessages, the post-upsert readback SELECT, and normalizeMessage
  surface the column back to callers.

Round-trip tests in apps/daemon/tests/db-pre-turn-file-names.test.ts
cover: write+listMessages, the UPDATE upsert path preserving the
baseline, and a legacy-row case returning undefined.

* fix(web): preserve terminal status + full multi-file diff on reattach

Two correctness issues caught in review of the prior reattach commits:

1. The reattach onDone path hard-coded `runStatus: 'succeeded'`, which
   overwrote a 'failed' or 'canceled' status that the replayed terminal
   event had already recorded via onRunStatus. Restored messages would
   come back as success even when the run had actually failed or been
   canceled. Now derives the final status from `prev.runStatus` via the
   existing `resolveSucceededRunStatus` helper, mirroring the send path
   at line 2333.

2. When `findExistingArtifactProjectFile()` recovered an existing
   on-disk artifact, the produced-files list was replaced with that
   single file, dropping any other files the turn had created earlier.
   Now always computes the full diff against `preTurnFileNames`, then
   appends the recovered artifact only if it isn't already in that
   set. Extracted as `mergeRecoveredArtifact(diff, recovered)` so the
   logic is a unit-testable invariant.

Tests in ProjectView.reattach-restore.test.tsx:
- mergeRecoveredArtifact: three cases (recovered appended to pre-files,
  no duplication when already in the diff, passthrough on no recovery).
- reattach failed-status: onRunStatus('failed') → onDone → final
  saveMessage has runStatus 'failed', not 'succeeded'.
- reattach canceled-status: same shape for cancellation.

* fix(web): force keepalive PUT on pagehide so the last buffered chunk survives reload

Review on #2383 caught that onPageHide() only called flush(), which
updates React state then schedules persistSoon() — a 500ms debounce.
On a hard reload the page tears down before that timer fires, so the
final ~250ms of streamed text never reaches the daemon.

Threaded a new flushAndPersistNow() callback through
createBufferedTextUpdates(). Both buffer call sites (send-path +
reattach-path) supply it backed by persistMessageById(id, { keepalive:
true }). saveMessage in state/projects.ts forwards the new
SaveMessageOptions.keepalive flag onto fetch's keepalive option, which
the browser honors specifically for unload-time requests.

onPageHide now calls flush() followed by flushAndPersistNow?.(), so:
- flush() pushes the buffered delta into React state synchronously
- the immediate persistMessageById then PUTs the updated message with
  keepalive:true, surviving document teardown

Regression test in ProjectView.reattach-restore.test.tsx: stream a
delta, dispatch pagehide, assert saveMessage was called with the
flushed content AND { keepalive: true } before the 500ms debounce
would otherwise have fired.
This commit is contained in:
Devayan Dewri 2026-05-22 16:17:12 +05:30 committed by GitHub
parent f799fbd7ed
commit 1b908a8481
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 749 additions and 17 deletions

View file

@ -94,6 +94,7 @@ function migrate(db: SqliteDb): void {
attachments_json TEXT,
produced_files_json TEXT,
feedback_json TEXT,
pre_turn_file_names_json TEXT,
started_at INTEGER,
ended_at INTEGER,
position INTEGER NOT NULL,
@ -228,6 +229,9 @@ function migrate(db: SqliteDb): void {
if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`);
}
if (!messageCols.some((c: DbRow) => c.name === 'pre_turn_file_names_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN pre_turn_file_names_json TEXT`);
}
const routineRunCols = db.prepare(`PRAGMA table_info(routine_runs)`).all() as DbRow[];
if (!routineRunCols.some((c: DbRow) => c.name === 'error_code')) {
db.exec(`ALTER TABLE routine_runs ADD COLUMN error_code TEXT`);
@ -874,6 +878,7 @@ export function listMessages(db: SqliteDb, conversationId: string) {
comment_attachments_json AS commentAttachmentsJson,
produced_files_json AS producedFilesJson,
feedback_json AS feedbackJson,
pre_turn_file_names_json AS preTurnFileNamesJson,
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
position
FROM messages
@ -895,7 +900,9 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
SET role = ?, content = ?, agent_id = ?, agent_name = ?,
run_id = ?, run_status = ?, last_run_event_id = ?,
events_json = ?, attachments_json = ?, comment_attachments_json = ?,
produced_files_json = ?, feedback_json = ?, started_at = ?, ended_at = ?
produced_files_json = ?, feedback_json = ?,
pre_turn_file_names_json = ?,
started_at = ?, ended_at = ?
WHERE id = ?`,
).run(
m.role,
@ -910,6 +917,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.feedback ? JSON.stringify(m.feedback) : null,
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
m.startedAt ?? null,
m.endedAt ?? null,
m.id,
@ -921,17 +929,18 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
)
.get(conversationId) as DbRow | undefined;
const position = (max?.m ?? -1) + 1;
// 18 values: id, conversation_id, role, content, agent_id, agent_name,
// 19 values: id, conversation_id, role, content, agent_id, agent_name,
// run_id, run_status, last_run_event_id, events_json, attachments_json,
// comment_attachments_json, produced_files_json, feedback_json, started_at, ended_at,
// position, created_at.
// comment_attachments_json, produced_files_json, feedback_json,
// pre_turn_file_names_json, started_at, ended_at, position, created_at.
db.prepare(
`INSERT INTO messages
(id, conversation_id, role, content, agent_id, agent_name,
run_id, run_status, last_run_event_id, events_json,
attachments_json, comment_attachments_json, produced_files_json,
feedback_json, started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
feedback_json, pre_turn_file_names_json,
started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
m.id,
conversationId,
@ -947,6 +956,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.feedback ? JSON.stringify(m.feedback) : null,
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
m.startedAt ?? null,
m.endedAt ?? null,
position,
@ -968,6 +978,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
comment_attachments_json AS commentAttachmentsJson,
produced_files_json AS producedFilesJson,
feedback_json AS feedbackJson,
pre_turn_file_names_json AS preTurnFileNamesJson,
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
position
FROM messages WHERE id = ?`,
@ -1256,6 +1267,7 @@ function normalizeMessage(row: DbRow) {
commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson),
producedFiles: parseJsonOrUndef(row.producedFilesJson),
feedback: parseJsonOrUndef(row.feedbackJson),
preTurnFileNames: parseJsonOrUndef(row.preTurnFileNamesJson),
createdAt: row.createdAt ?? undefined,
startedAt: row.startedAt ?? undefined,
endedAt: row.endedAt ?? undefined,

View file

@ -133,12 +133,21 @@ export function createChatRunService({
const stream = (run, req, res) => {
const sse = createSseResponse(res);
const lastEventId = Number(req.get('Last-Event-ID') || req.query.after || 0);
let sent = 0;
for (const record of run.events) {
if (!Number.isFinite(lastEventId) || record.id > lastEventId) {
sse.send(record.event, record.data, record.id);
sent++;
}
}
if (TERMINAL_RUN_STATUSES.has(run.status)) {
// Guarantee a reattaching client sees a terminal signal even if its
// cursor is at or past the final event id — otherwise the SSE
// stream ends silently and the client falls back to status-only fetch.
if (sent === 0 && run.events.length > 0) {
const last = run.events[run.events.length - 1];
sse.send(last.event, last.data, last.id);
}
sse.end();
return;
}

View file

@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
listMessages,
openDatabase,
upsertMessage,
} from '../src/db.js';
describe('preTurnFileNames persistence', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-db-pre-turn-'));
});
afterEach(() => {
closeDatabase();
rmSync(tempDir, { recursive: true, force: true });
});
function seedConversation(db: ReturnType<typeof openDatabase>) {
const now = Date.now();
insertProject(db, { id: 'proj-1', name: 'P', createdAt: now, updatedAt: now });
insertConversation(db, {
id: 'conv-1',
projectId: 'proj-1',
title: 'C',
createdAt: now,
updatedAt: now,
});
return now;
}
it('round-trips preTurnFileNames through upsert and listMessages', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
const now = seedConversation(db);
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
runId: 'run-1',
runStatus: 'running',
startedAt: now,
preTurnFileNames: ['existing.html', 'README.md'],
});
const reloaded = listMessages(db, 'conv-1');
expect(reloaded).toHaveLength(1);
expect(reloaded[0]!.preTurnFileNames).toEqual(['existing.html', 'README.md']);
});
it('preserves preTurnFileNames across a subsequent UPDATE upsert that omits the field', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
const now = seedConversation(db);
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
runId: 'run-1',
runStatus: 'running',
startedAt: now,
preTurnFileNames: ['existing.html'],
});
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: 'streamed chunk',
runId: 'run-1',
runStatus: 'running',
startedAt: now,
preTurnFileNames: ['existing.html'],
});
const [msg] = listMessages(db, 'conv-1');
expect(msg).toBeDefined();
expect(msg!.preTurnFileNames).toEqual(['existing.html']);
});
it('returns undefined when no baseline was ever written (legacy messages)', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
const now = seedConversation(db);
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
runStatus: 'running',
startedAt: now,
});
const [msg] = listMessages(db, 'conv-1');
expect(msg).toBeDefined();
expect(msg!.preTurnFileNames).toBeUndefined();
});
});

View file

@ -94,6 +94,77 @@ describe('chat run service shutdown', () => {
});
});
describe('chat run service stream replay', () => {
it('always replays the final event when a reattaching client cursor is at the end of a terminal run', () => {
const sendCalls: Array<{ event: string; data: unknown; id: number }> = [];
const endCalls: number[] = [];
const runs = createChatRunService({
createSseResponse: () => ({
send: vi.fn((event: string, data: unknown, id: number) => {
sendCalls.push({ event, data, id });
return true;
}),
end: vi.fn(() => endCalls.push(1)),
cleanup: vi.fn(),
}),
createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }),
shutdownGraceMs: 10,
ttlMs: 60_000,
});
const run = runs.create({ projectId: 'p', conversationId: 'c' }) as any;
runs.emit(run, 'stdout', { text: 'hello' });
runs.finish(run, 'succeeded', 0, null);
const finalEventId = run.events.at(-1).id;
const fakeReq = {
get: () => null,
query: { after: String(finalEventId) },
} as never;
const fakeRes = { on: () => {} } as never;
sendCalls.length = 0;
runs.stream(run, fakeReq, fakeRes);
expect(sendCalls.length).toBeGreaterThanOrEqual(1);
expect(sendCalls.at(-1)?.event).toBe('end');
expect(endCalls.length).toBe(1);
});
it('does not duplicate events when the cursor sits before the final event', () => {
const sendCalls: Array<{ event: string; data: unknown; id: number }> = [];
const runs = createChatRunService({
createSseResponse: () => ({
send: vi.fn((event: string, data: unknown, id: number) => {
sendCalls.push({ event, data, id });
return true;
}),
end: vi.fn(),
cleanup: vi.fn(),
}),
createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }),
shutdownGraceMs: 10,
ttlMs: 60_000,
});
const run = runs.create() as any;
runs.emit(run, 'stdout', { text: 'a' });
runs.emit(run, 'stdout', { text: 'b' });
runs.finish(run, 'succeeded', 0, null);
const cursor = run.events[0].id;
runs.stream(
run,
{ get: () => null, query: { after: String(cursor) } } as never,
{ on: () => {} } as never,
);
expect(sendCalls.map((c) => c.id)).toEqual(
run.events.filter((e: { id: number }) => e.id > cursor).map((e: { id: number }) => e.id),
);
});
});
function createRuns() {
return createChatRunService({
createSseResponse: () => ({

View file

@ -1810,6 +1810,7 @@ export function ProjectView({
const textBuffer = createBufferedTextUpdates({
updateMessage: (updater) => updateMessageById(message.id, updater),
persistSoon,
flushAndPersistNow: () => persistNow({ keepalive: true }),
onContentDelta: applyContentDelta,
});
reattachTextBuffersRef.current.add(textBuffer);
@ -1853,7 +1854,7 @@ export function ProjectView({
...prev,
content: needsFullReplay ? replayedContent : prev.content,
events: needsFullReplay ? replayedEvents : prev.events,
runStatus: 'succeeded',
runStatus: resolveSucceededRunStatus(prev.runStatus),
endedAt: prev.endedAt ?? Date.now(),
}),
true,
@ -1865,9 +1866,12 @@ export function ProjectView({
clearActiveRunRefs(reattachConversationId, controller, cancelController);
clearStreamingMarker(reattachConversationId);
void (async () => {
const beforeFiles = await refreshProjectFiles();
const beforeFileNames = new Set(beforeFiles.map((f) => f.name));
let nextFiles = beforeFiles;
const preTurn = message.preTurnFileNames;
let nextFiles = await refreshProjectFiles();
// Use the turn-start snapshot when available so reload
// recovers files produced before the artifact write too;
// fall back to the current list for legacy messages.
const beforeFileNames = new Set(preTurn ?? nextFiles.map((f) => f.name));
let recoveredExistingArtifact: ProjectFile | null = null;
if (parsedArtifact?.html) {
const runStartedAt = status.createdAt || message.startedAt || message.createdAt;
@ -1884,9 +1888,8 @@ export function ProjectView({
nextFiles = await refreshProjectFiles();
}
}
const produced = recoveredExistingArtifact
? [recoveredExistingArtifact]
: nextFiles.filter((f) => !beforeFileNames.has(f.name));
const diff = nextFiles.filter((f) => !beforeFileNames.has(f.name));
const produced = mergeRecoveredArtifact(diff, recoveredExistingArtifact);
if (produced.length > 0) {
updateMessageById(
message.id,
@ -2038,6 +2041,7 @@ export function ProjectView({
)
: apiProtocolModelLabel(config.apiProtocol, config.model);
const assistantId = randomUUID();
const preTurnFileNames = projectFiles.map((f) => f.name);
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'assistant',
@ -2048,6 +2052,7 @@ export function ProjectView({
createdAt: startedAt,
runStatus: config.mode === 'daemon' ? 'running' : undefined,
startedAt,
preTurnFileNames,
};
let latestAssistantMsg: ChatMessage = assistantMsg;
const updateConversationLatestRun = (
@ -2133,10 +2138,7 @@ export function ProjectView({
}
}
// Snapshot the file list at turn-start so we can diff after the
// agent finishes and surface anything new (e.g. a generated .pptx)
// as download chips on the assistant message.
const beforeFileNames = new Set(projectFiles.map((f) => f.name));
const beforeFileNames = new Set(preTurnFileNames);
const parser = createArtifactParser();
let parsedArtifact: Artifact | null = null;
@ -2161,6 +2163,13 @@ export function ProjectView({
persistMessageById(assistantId);
}, 500);
};
const persistAssistantNowKeepalive = () => {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
persistMessageById(assistantId, { keepalive: true });
};
const pushEvent = (ev: AgentEvent) => {
textBuffer.flush();
updateAssistant((prev) => ({ ...prev, events: [...(prev.events ?? []), ev] }));
@ -2262,6 +2271,7 @@ export function ProjectView({
const textBuffer = createBufferedTextUpdates({
updateMessage: updateAssistant,
persistSoon: persistAssistantSoon,
flushAndPersistNow: persistAssistantNowKeepalive,
onContentDelta: applyContentDelta,
});
sendTextBufferRef.current = textBuffer;
@ -3934,6 +3944,27 @@ export function resolveSucceededRunStatus(status: ChatMessage['runStatus']): Cha
return status === 'failed' || status === 'canceled' ? status : 'succeeded';
}
export function computeProducedFiles(
beforeNames: ReadonlySet<string> | readonly string[] | undefined,
next: readonly ProjectFile[],
): ProjectFile[] | undefined {
if (!beforeNames) return undefined;
const set = beforeNames instanceof Set ? beforeNames : new Set(beforeNames);
return next.filter((f) => !set.has(f.name));
}
// Reattach with a recovered (on-disk) artifact must still include any
// other files the turn produced before the artifact write — replacing
// the diff with a single file was the regression noted on PR #2383.
export function mergeRecoveredArtifact(
diff: readonly ProjectFile[],
recovered: ProjectFile | null,
): ProjectFile[] {
if (!recovered) return [...diff];
if (diff.some((f) => f.name === recovered.name)) return [...diff];
return [...diff, recovered];
}
export function clearStreamingConversationMarker(
currentConversationId: string | null,
completedConversationId?: string | null,
@ -3980,10 +4011,15 @@ type BufferedTextUpdates = ReturnType<typeof createBufferedTextUpdates>;
function createBufferedTextUpdates({
updateMessage,
persistSoon,
flushAndPersistNow,
onContentDelta,
}: {
updateMessage: (updater: (prev: ChatMessage) => ChatMessage) => void;
persistSoon: () => void;
// Synchronous flush + persist with a transport that survives page
// unload (PUT with keepalive). Invoked by the pagehide handler so the
// last buffered chunk isn't lost when the user reloads mid-stream.
flushAndPersistNow?: () => void;
onContentDelta?: (delta: string) => void;
}) {
let pendingContentDelta = '';
@ -3994,6 +4030,7 @@ function createBufferedTextUpdates({
let flushing = false;
let needsFlush = false;
const hasDocument = typeof document !== 'undefined';
const hasWindow = typeof window !== 'undefined';
const cancelScheduledFlush = () => {
if (flushFrame !== null) {
@ -4085,6 +4122,9 @@ function createBufferedTextUpdates({
if (hasDocument) {
document.removeEventListener('visibilitychange', onVisibilityChange);
}
if (hasWindow) {
window.removeEventListener('pagehide', onPageHide);
}
};
function onVisibilityChange() {
@ -4093,9 +4133,19 @@ function createBufferedTextUpdates({
}
}
function onPageHide() {
flush();
// persistSoon's 500ms debounce never fires once the document tears
// down, so synchronously PUT with keepalive instead.
flushAndPersistNow?.();
}
if (hasDocument) {
document.addEventListener('visibilitychange', onVisibilityChange);
}
if (hasWindow) {
window.addEventListener('pagehide', onPageHide);
}
return { appendContent, appendTextEvent, appendEvent, flush, cancel };
}

View file

@ -352,6 +352,11 @@ export async function listMessages(
export interface SaveMessageOptions {
telemetryFinalized?: boolean;
// Set during page-unload paths (pagehide / visibilitychange→hidden) so
// the in-flight PUT survives even if the document tears down before the
// response arrives. Without keepalive the browser cancels the fetch
// and the daemon never sees the final buffered text chunk.
keepalive?: boolean;
}
export async function saveMessage(
@ -370,6 +375,7 @@ export async function saveMessage(
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
...(options.keepalive ? { keepalive: true } : {}),
},
);
} catch {

View file

@ -0,0 +1,482 @@
// @vitest-environment jsdom
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ProjectView,
computeProducedFiles,
mergeRecoveredArtifact,
} from '../../src/components/ProjectView';
import type { ChatMessage } from '../../src/types';
const listConversations = vi.fn();
const listMessages = vi.fn();
const fetchPreviewComments = vi.fn();
const loadTabs = vi.fn();
const fetchProjectFiles = vi.fn();
const fetchProjectDesignSystemPackageAudit = vi.fn();
const fetchLiveArtifacts = vi.fn();
const fetchSkill = vi.fn();
const fetchDesignSystem = vi.fn();
const getTemplate = vi.fn();
const fetchChatRunStatus = vi.fn();
const listActiveChatRuns = vi.fn();
const reattachDaemonRun = vi.fn();
const streamViaDaemon = vi.fn();
const saveMessage = vi.fn();
const createConversation = vi.fn();
const patchConversation = vi.fn();
const patchProject = vi.fn();
const saveTabs = vi.fn();
vi.mock('../../src/i18n', () => ({
useT: () => ((value: string) => value),
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: vi.fn(),
}));
vi.mock('../../src/providers/daemon', () => ({
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args),
}));
vi.mock('../../src/providers/registry', () => ({
deletePreviewComment: vi.fn(),
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
fetchProjectDesignSystemPackageAudit: (...args: unknown[]) =>
fetchProjectDesignSystemPackageAudit(...args),
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: vi.fn(),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/state/projects', () => ({
createConversation: (...args: unknown[]) => createConversation(...args),
deleteConversation: vi.fn(),
getTemplate: (...args: unknown[]) => getTemplate(...args),
listConversations: (...args: unknown[]) => listConversations(...args),
listMessages: (...args: unknown[]) => listMessages(...args),
loadTabs: (...args: unknown[]) => loadTabs(...args),
patchConversation: (...args: unknown[]) => patchConversation(...args),
patchProject: (...args: unknown[]) => patchProject(...args),
saveMessage: (...args: unknown[]) => saveMessage(...args),
saveTabs: (...args: unknown[]) => saveTabs(...args),
}));
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: () => null,
}));
vi.mock('../../src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: () => null,
}));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: () => null,
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => null,
}));
function renderProjectView() {
return render(
<ProjectView
project={
{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never
}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'agent-1',
notifications: undefined,
agentModels: {},
} as never
}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
}
describe('computeProducedFiles', () => {
it('returns files not present in the before-set', () => {
const before = ['existing.html'];
const next = [
{ name: 'existing.html', path: '/p/existing.html', size: 1, updatedAt: 0 },
{ name: 'new.pptx', path: '/p/new.pptx', size: 2, updatedAt: 0 },
];
const produced = computeProducedFiles(before, next as never);
expect(produced?.map((f) => f.name)).toEqual(['new.pptx']);
});
it('returns undefined when no baseline is provided', () => {
expect(computeProducedFiles(undefined, [] as never)).toBeUndefined();
});
});
describe('mergeRecoveredArtifact', () => {
const fileA = { name: 'helper.txt', path: '/p/helper.txt', size: 1, updatedAt: 0 };
const artifact = { name: 'deck.html', path: '/p/deck.html', size: 9, updatedAt: 0 };
it('keeps pre-artifact files when a recovered artifact is appended', () => {
const merged = mergeRecoveredArtifact([fileA] as never, artifact as never);
expect(merged.map((f) => f.name)).toEqual(['helper.txt', 'deck.html']);
});
it('does not duplicate the artifact if the diff already contains it', () => {
const merged = mergeRecoveredArtifact(
[fileA, artifact] as never,
artifact as never,
);
expect(merged.map((f) => f.name)).toEqual(['helper.txt', 'deck.html']);
});
it('returns the diff unchanged when no artifact was recovered', () => {
const merged = mergeRecoveredArtifact([fileA] as never, null);
expect(merged.map((f) => f.name)).toEqual(['helper.txt']);
});
});
describe('ProjectView daemon reattach restore', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
window.sessionStorage.clear();
});
it('populates producedFiles on the persisted message after reattach completes', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runId: 'run-1',
runStatus: 'running',
preTurnFileNames: ['existing.html'],
} satisfies ChatMessage,
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
const beforeFiles = [
{ name: 'existing.html', path: '/p/existing.html', size: 1, updatedAt: 0 },
];
const afterFiles = [
...beforeFiles,
{ name: 'new.pptx', path: '/p/new.pptx', size: 2, updatedAt: 0 },
];
fetchProjectFiles.mockResolvedValueOnce(beforeFiles).mockResolvedValue(afterFiles);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-1',
status: 'running',
createdAt: startedAt,
updatedAt: startedAt,
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
let capturedHandlers: {
onDelta: (text: string) => void;
onAgentEvent: (ev: unknown) => void;
onDone: () => void;
} | null = null;
reattachDaemonRun.mockImplementation(
async (options: { handlers: { onDelta: any; onAgentEvent: any; onDone: any } }) => {
capturedHandlers = options.handlers;
return new Promise<void>(() => {});
},
);
renderProjectView();
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(capturedHandlers).not.toBeNull();
capturedHandlers!.onDelta('hello ');
capturedHandlers!.onAgentEvent({ kind: 'thinking', text: 'reasoning step' });
capturedHandlers!.onDelta('world');
capturedHandlers!.onDone();
await waitFor(() => {
const lastWithProduced = saveMessage.mock.calls
.map((call) => call[2] as ChatMessage)
.filter((m) => m?.id === 'msg-reattach' && Array.isArray(m.producedFiles))
.at(-1);
expect(lastWithProduced?.producedFiles?.map((f) => f.name)).toEqual(['new.pptx']);
expect(lastWithProduced?.runStatus).toBe('succeeded');
});
});
it('reaches succeeded state via the SSE end event even when only the terminal event replays', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-late',
role: 'assistant',
content: 'partial',
createdAt: startedAt,
startedAt,
runId: 'run-late',
runStatus: 'running',
preTurnFileNames: [],
} satisfies ChatMessage,
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-late',
status: 'succeeded',
createdAt: startedAt,
updatedAt: startedAt,
exitCode: 0,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
let capturedOnDone: (() => void) | null = null;
reattachDaemonRun.mockImplementation(
async (options: { handlers: { onDone: () => void } }) => {
capturedOnDone = options.handlers.onDone;
return new Promise<void>(() => {});
},
);
renderProjectView();
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(capturedOnDone).not.toBeNull();
capturedOnDone!();
await waitFor(() => {
const succeeded = saveMessage.mock.calls
.map((call) => call[2] as ChatMessage)
.find((m) => m?.id === 'msg-late' && m.runStatus === 'succeeded');
expect(succeeded).toBeTruthy();
});
});
it('preserves failed runStatus when onRunStatus records failure before onDone fires', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-fail',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runId: 'run-fail',
runStatus: 'running',
preTurnFileNames: [],
} satisfies ChatMessage,
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-fail',
status: 'failed',
createdAt: startedAt,
updatedAt: startedAt,
exitCode: 1,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
let captured: {
onDone: () => void;
onRunStatus: (s: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled') => void;
} | null = null;
reattachDaemonRun.mockImplementation(async (options: any) => {
captured = { onDone: options.handlers.onDone, onRunStatus: options.onRunStatus };
return new Promise<void>(() => {});
});
renderProjectView();
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(captured).not.toBeNull();
captured!.onRunStatus('failed');
captured!.onDone();
await waitFor(() => {
const finalSave = saveMessage.mock.calls
.map((call) => call[2] as ChatMessage)
.filter((m) => m?.id === 'msg-fail' && (m.runStatus === 'failed' || m.runStatus === 'succeeded'))
.at(-1);
expect(finalSave?.runStatus).toBe('failed');
});
});
it('preserves canceled runStatus when onRunStatus records cancellation before onDone fires', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-cancel',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runId: 'run-cancel',
runStatus: 'running',
preTurnFileNames: [],
} satisfies ChatMessage,
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-cancel',
status: 'canceled',
createdAt: startedAt,
updatedAt: startedAt,
exitCode: null,
signal: 'SIGTERM',
});
listActiveChatRuns.mockResolvedValue([]);
let captured: { onDone: () => void; onRunStatus: (s: any) => void } | null = null;
reattachDaemonRun.mockImplementation(async (options: any) => {
captured = { onDone: options.handlers.onDone, onRunStatus: options.onRunStatus };
return new Promise<void>(() => {});
});
renderProjectView();
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
captured!.onRunStatus('canceled');
captured!.onDone();
await waitFor(() => {
const finalSave = saveMessage.mock.calls
.map((call) => call[2] as ChatMessage)
.filter((m) => m?.id === 'msg-cancel' && (m.runStatus === 'canceled' || m.runStatus === 'succeeded'))
.at(-1);
expect(finalSave?.runStatus).toBe('canceled');
});
});
it('persists the last buffered delta immediately on pagehide instead of waiting for the 500ms debounce', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-unload',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runId: 'run-unload',
runStatus: 'running',
preTurnFileNames: [],
} satisfies ChatMessage,
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-unload',
status: 'running',
createdAt: startedAt,
updatedAt: startedAt,
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
let capturedOnDelta: ((text: string) => void) | null = null;
reattachDaemonRun.mockImplementation(async (options: any) => {
capturedOnDelta = options.handlers.onDelta;
return new Promise<void>(() => {});
});
renderProjectView();
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(capturedOnDelta).not.toBeNull();
// Stream a delta. persistSoon would schedule a save in 500ms, but the
// page is about to be torn down — anything not yet persisted is lost.
capturedOnDelta!('last buffered chunk');
// Page reload fires pagehide synchronously while the document is still
// alive; the buffered chunk must reach saveMessage with keepalive=true
// BEFORE the debounce timer would otherwise fire.
saveMessage.mockClear();
window.dispatchEvent(new Event('pagehide'));
await waitFor(() => {
const keepaliveSave = saveMessage.mock.calls.find((call) => {
const msg = call[2] as ChatMessage;
const opts = call[3] as { keepalive?: boolean } | undefined;
return (
msg?.id === 'msg-unload' &&
typeof msg.content === 'string' &&
msg.content.includes('last buffered chunk') &&
opts?.keepalive === true
);
});
expect(keepaliveSave).toBeTruthy();
});
});
});

View file

@ -173,6 +173,8 @@ export interface ChatMessage {
attachments?: ChatAttachment[];
commentAttachments?: ChatCommentAttachment[];
producedFiles?: ProjectFile[];
// Diff baseline so reattach can rebuild producedFiles after reload.
preTurnFileNames?: string[];
feedback?: ChatMessageFeedback;
/**
* Request-only marker for the final assistant-message persistence pass.