mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
f799fbd7ed
commit
1b908a8481
8 changed files with 749 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
100
apps/daemon/tests/db-pre-turn-file-names.test.ts
Normal file
100
apps/daemon/tests/db-pre-turn-file-names.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
482
apps/web/tests/components/ProjectView.reattach-restore.test.tsx
Normal file
482
apps/web/tests/components/ProjectView.reattach-restore.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue