open-design/apps/daemon/tests/db-pre-turn-file-names.test.ts
Devayan Dewri 1b908a8481
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.
2026-05-22 18:47:12 +08:00

100 lines
2.8 KiB
TypeScript

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();
});
});