open-design/apps/daemon/tests/transcript-export.test.ts
Bryan c530d163f8
feat(web): "Resume conversation in new chat" UI — #462 Commit B (companion to #1718) (#2264)
* feat(contracts): add handoff request/response DTOs

Adds HandoffRequest, HandoffResponse, and HANDOFF_SCHEMA_VERSION for
the upcoming POST /api/projects/:id/handoff synthesis endpoint. Mirrors
the finalize.ts subpath pattern (package.json#exports + esbuild entry +
index re-export) so daemon and web can import
@open-design/contracts/api/handoff.

Refs nexu-io/open-design#462.

* feat(daemon): add handoff synthesis pipeline (buildHandoffPrompt + synthesizeHandoffPrompt)

Adds `apps/daemon/src/handoff-design.ts` exposing the resume-conversation
synthesis primitives the upcoming `POST /api/projects/:id/handoff` route will
call into.

- `buildHandoffPrompt({ projectId, transcriptJsonl, transcriptMessageCount,
  now })` returns the system + user prompts. System prompt asks Claude to
  emit a structured Markdown body with Context / Decisions made / Open
  questions / Current focus / Provenance, with Provenance bullets explicitly
  flat (no Markdown emphasis on labels) to preempt the PR #1584 round-2
  parser bug.
- `synthesizeHandoffPrompt(db, projectsRoot, projectId, options)` reuses the
  existing finalize-design pipeline pieces: `exportProjectTranscript` →
  `truncateTranscriptForPrompt` → `buildHandoffPrompt` →
  `callAnthropicWithRetry` → `extractDesignMd`, but without the lockfile,
  disk write, design-system, or artifact-resolution paths.
- Promotes `DEFAULT_TIMEOUT_MS` in finalize-design.ts to `export const` so
  handoff shares the same 120s upstream-call bound.

Refs nexu-io/open-design#462.

* feat(daemon): wire POST /api/projects/:id/handoff route

Adds the handoff HTTP route and registers it in server.ts. Validation
block + error-mapping shape mirror registerFinalizeRoutes (BYOK payload,
upstream-error → ApiErrorCode mapping, redactSecrets on the raw upstream
body). Handoff has no lockfile, so the CONFLICT branch is omitted.

`res.on('close')` is wired to flip an AbortController whose signal is
threaded into synthesizeHandoffPrompt, so a UI-side cancel actually
aborts the daemon-side Anthropic call rather than letting it keep
running after the client walks away (mirrors the PR #974 fix for
finalize).

- `apps/daemon/src/handoff-routes.ts` — new, exports registerHandoffRoutes
  + RegisterHandoffRoutesDeps.
- `apps/daemon/src/server-context.ts` — adds handoff slot to ServerContext.
- `apps/daemon/src/route-context-contract.ts` — adds RegisterHandoffRoutesDeps
  to the compile-time coverage assertion.
- `apps/daemon/src/server.ts` — imports synthesizeHandoffPrompt +
  registerHandoffRoutes, builds handoffDeps, registers the route next
  to finalize.
- `apps/daemon/tests/handoff-route.test.ts` — 12 HTTP-layer tests:
  validation (400/403/404), happy path, upstream error mapping
  (401/429/502/502 non-JSON), api-key redaction.
- `apps/daemon/tests/handoff-route-abort.test.ts` — client-disconnect
  aborts the daemon-side controller.

Refs nexu-io/open-design#462.

* fix(daemon): map TranscriptExportLockedError to 409 CONFLICT on handoff route

`exportProjectTranscript` acquires a per-project `.transcript.lock`
internally (apps/daemon/src/transcript-export.ts:131-163) and throws
`TranscriptExportLockedError` on EEXIST. Concurrent handoff requests —
or a handoff that races `/api/projects/:id/finalize/anthropic` — lost
that lock and surfaced as 500 INTERNAL_ERROR through the route's
generic catch.

- `apps/daemon/src/handoff-routes.ts` — catch `TranscriptExportLockedError`
  and return `409 CONFLICT` ahead of the generic 500 branch, mirroring
  the existing `FinalizePackageLockedError → 409 CONFLICT` mapping at
  `apps/daemon/src/import-export-routes.ts:603-605`.
- `apps/daemon/src/server.ts` — thread `TranscriptExportLockedError`
  through `handoffDeps` so the route can match without a direct import.
- `apps/daemon/src/handoff-design.ts` — correct the module header
  comment that incorrectly claimed "no lockfile (concurrent handoff
  calls are safe)" — handoff does not add its own lock, but it does
  transitively acquire `.transcript.lock` via the transcript-export
  call.
- `apps/daemon/tests/handoff-route.test.ts` — regression test that
  pre-acquires `.transcript.lock` on disk via `fs.openSync(lockPath, 'wx')`
  before firing a handoff request, asserts 409 CONFLICT.

Refs nexu-io/open-design#462 — addresses @nettee's blocking review on
PR #1718 (comment 3242251338).

* fix(daemon): keep handoff request timeout armed through the response body read

`synthesizeHandoffPrompt` cleared the upstream-call timeout in a `finally`
that ran as soon as `callAnthropicWithRetry` returned. But `fetch()`
resolves once the upstream sends *headers* — so the subsequent
`await response.json()` body read ran with no timeout. A response that
sends headers and then stalls its body could hang `/api/projects/:id/handoff`
indefinitely instead of failing.

- `apps/daemon/src/handoff-design.ts` — move `clearTimeout(timeoutId)` into a
  single outer `finally` spanning both the call and the `response.json()`
  body parse, so the timeout stays armed until the body is fully consumed.
- `apps/daemon/src/handoff-design.ts` — the body-parse catch now re-throws
  `AbortError` as-is, mirroring the call-phase catch. Without this a
  body-phase timeout would surface as `502` "non-JSON body"; re-throwing
  lets the route map it to the intended `503` "handoff timed out"
  (`handoff-routes.ts:122-124`).
- `apps/daemon/tests/handoff-design.test.ts` — regression test: a `fetchImpl`
  returning a `Response` whose body never closes after headers, raced
  against a 500ms deadline, asserts the call aborts (not hangs) and rejects
  with `AbortError`.

Refs nexu-io/open-design#462 — addresses @nettee's round-2 blocking review
on PR #1718 (`handoff-design.ts:196`).

* fix(daemon): map upstream 400 to 400 BAD_REQUEST on handoff route

`callAnthropicWithRetry` preserves a non-retryable upstream status, so an
Anthropic HTTP 400 (`invalid_request_error` — unknown model, invalid
maxTokens, malformed body) reached the route's `FinalizeUpstreamError`
branch and fell through to `502 UPSTREAM_UNAVAILABLE`. That reported
deterministic caller input as a transient server outage, inviting
pointless retries and hiding which field was wrong.

- `apps/daemon/src/handoff-routes.ts` — special-case `err.status === 400`
  to `400 BAD_REQUEST` with the redacted upstream detail, ahead of the
  generic 502. Also refresh the route docblock: it claimed the 409 branch
  was omitted (stale since the R1 TranscriptExportLockedError fix) and
  that error mapping fully mirrors finalize (now diverges on 400).
- `apps/daemon/tests/handoff-route.test.ts` — route test driving an
  Anthropic `400 invalid_request_error`: asserts 400 BAD_REQUEST, the
  upstream detail is surfaced, and an echoed key is redacted.
- `packages/contracts/tests/package-runtime.test.ts` — import
  `@open-design/contracts/api/handoff` through the package `exports` map
  and assert `HANDOFF_SCHEMA_VERSION`, covering the built publish surface
  (esbuild entry + exports map + root re-export) that the source-only
  `handoff-contract.test.ts` does not exercise.

Refs nexu-io/open-design#462 — addresses @nettee's round-3 blocking
review on PR #1718.

* fix(daemon): await the now-async external base-URL validator on handoff route

Main's #1176 (`9a64fccd`) made `validateExternalApiBaseUrl` DNS-aware and
asynchronous (`validateBaseUrlResolved`) and updated the proxy and finalize
callers to `await` it. The handoff route — added on this branch in parallel,
against the old synchronous validator — still called it without `await`, so
`validated` was a Promise: `validated.error` / `validated.forbidden` were
`undefined`, the SSRF / malformed-URL guard silently no-opped, and a bad
`baseUrl` fell through to the upstream call and surfaced as 502.

A semantic merge break — no textual conflict, green on the branch in
isolation, red once CI re-merged latest main.

- `apps/daemon/src/handoff-routes.ts` — `await validateExternalApiBaseUrl(...)`,
  mirroring the finalize route (`import-export-routes.ts:561`). The handler
  is already `async`.

The existing `handoff-route.test.ts` cases "400 BAD_REQUEST when baseUrl is
not a valid URL" and "403 FORBIDDEN when baseUrl points at a private internal
IP" already encode this — red against branch + latest main, green now.

Refs nexu-io/open-design#462 — PR #1718 CI fix.

* chore(daemon): list handoff in the assertServerContextSatisfiesRoutes literal

The `assertServerContextSatisfiesRoutes({...})` call in `server.ts` enumerates
every route registrar's deps but omitted `handoff`. Adding `handoff: handoffDeps`
makes the literal complete and consistent with the other route deps.

This was not a typecheck break: route-dep coverage is guaranteed by the
`Assert<ServerContext extends AllRegisteredRouteDeps>` type in
`route-context-contract.ts` — and `AllRegisteredRouteDeps` already includes
`RegisterHandoffRoutesDeps` — not by this assertion-call literal. The literal
has omitted `handoff` since this branch's first push (`806db576`) through green
CI throughout; `tsc -p tsconfig.json --noEmit` is clean before and after.

Refs nexu-io/open-design#462 — addresses @nettee's round-4 review note on PR #1718.

* feat(web): add "Resume conversation in new chat" action (#462)

Adds a Resume control to the chat header, next to "New conversation".
Clicking it synthesizes a handoff prompt from the current transcript
via POST /api/projects/:id/handoff, opens a fresh conversation, and
auto-sends the synthesized prompt as its first user message — so a
drifted session resumes without the user replaying context by hand.
The old conversation is preserved.

- synthesizeHandoff() web-state wrapper in apps/web/src/state/projects.ts
- resume-conversation icon button in ChatPane (onResumeConversation /
  resumeConversationDisabled props)
- handleResumeConversation + pendingResumeRef + auto-send effect in
  ProjectView; effect gates on messagesConversationId so the prompt
  cannot fire before the new conversation's message read settles
- chat.resumeConversation i18n key across all 19 locales

Commit B of #462; Commit A is the daemon endpoint (PR #1718). This
branch is stacked on feat/handoff-endpoint so the web code resolves
@open-design/contracts/api/handoff.

* fix(daemon): scope handoff to one conversation + reject empty transcripts (#462)

Addresses the review on #1718 and #2264:

- mrcfps (#2264): the handoff endpoint exported the whole project's
  transcript, so a multi-conversation project blended unrelated chats
  into the synthesized prompt. HandoffRequest now carries a required
  conversationId; the route validates it belongs to the project
  (404 CONVERSATION_NOT_FOUND), and exportProjectTranscript takes an
  optional conversationId filter so only that conversation is exported.
- nettee (#1718): a zero-message conversation still called Anthropic and
  fabricated a handoff. synthesizeHandoffPrompt now throws
  EmptyTranscriptError on messageCount === 0; the route maps it to
  400 EMPTY_TRANSCRIPT before any BYOK tokens are spent.

HANDOFF_SCHEMA_VERSION bumped to 2 (conversationId is a new required
request field). Regression tests: a two-conversation scoping test, an
empty-conversation route + pipeline test, and a transcript-export
conversationId-filter unit test.

* feat(web): send conversationId with the resume handoff request (#462)

Follows the handoff endpoint becoming conversation-scoped. The resume
flow now passes the active conversationId to POST /handoff so the
synthesized prompt summarizes only the conversation being resumed.
handleResumeConversation bails when there is no active conversation;
synthesizeHandoff and the resume tests carry the new field.

* feat(daemon): add `od project handoff` CLI + register handoff error codes (#462)

Addresses the second-round review on #1718 and #2264:

- mrcfps (#2264): per AGENTS.md "Capability exposure (UI/CLI dual-track)",
  a user-facing capability must be reachable through the `od` CLI, not
  only the web UI. Adds `od project handoff <id> --conversation <id>
  --api-key <key> --model <model> [--base-url] [--max-tokens] [--json]`,
  driving the same POST /api/projects/:id/handoff endpoint. The logic
  lives in a testable handoff-cli.ts sibling module (mirrors
  artifacts-cli.ts) so cli.ts's import-time dispatch stays out of tests.
- nettee (#1718): the route emitted CONVERSATION_NOT_FOUND and
  EMPTY_TRANSCRIPT, which were absent from the shared API_ERROR_CODES
  union. Both are now registered in packages/contracts/src/errors.ts,
  with a contract test pinning them so the route and contract cannot
  drift again.

A CLI contract test covers the conversation-scoped request shape,
--json output, flag validation, and daemon-error surfacing.

* fix(daemon): fail `od project handoff` on a malformed 2xx response (#462)

Addresses nettee's review on #1718: runProjectHandoff treated any 2xx
response as success, so a broken daemon/proxy 200 with malformed or
shape-invalid JSON would print `undefined` (or `{}` under --json) and
still exit 0 — breaking the fail-fast contract scripts rely on. It now
validates the body is a well-formed HandoffResponse via an
isHandoffResponse type guard and fails fast otherwise. Regression tests
cover a shape-invalid and an unparseable 200 body.

* feat(web): surface the daemon's classified handoff error in the resume toast (#462)

Addresses mrcfps's non-blocking note on #2264: synthesizeHandoff returned
null for every non-2xx response, so RATE_LIMITED, EMPTY_TRANSCRIPT, and an
upstream 400 with provider detail all collapsed into one generic "check
your API key" toast — even though handoff-routes.ts had already classified
and sanitized them.

synthesizeHandoff now returns the daemon's structured `{ error }` on a
classified failure; `null` stays reserved for a transport failure or an
unparseable body. handleResumeConversation surfaces error.message plus
redacted details for the `{ error }` case, and a distinct
daemon-unreachable message for null.

* fix(web): omit empty baseUrl from the resume handoff request (#462)

Addresses mrcfps's review on #2264: the default Anthropic config
normalizes baseUrl to '' (config.ts), and the handoff route 400s an
explicit empty baseUrl — so the Resume action failed before synthesis
for every user who never set a custom base URL.

handleResumeConversation now forwards baseUrl only when config.baseUrl
is a non-empty string, matching the contract's optional-field semantics.
Tests: the default-config path asserts baseUrl is absent from the
request, and a new case covers a custom baseUrl being forwarded.

* refactor(daemon): dispatch `od project handoff` before the generic project parser (#462)

Addresses nettee's non-blocking note on #1718: runProject ran the shared
parseFlags(PROJECT_*) before reaching the handoff switch case, so a
malformed `od project handoff` invocation (`--unknown`, `--max-tokens`
with no value) threw out of the generic parser instead of hitting
handoff-cli's structured fail() — the entrypoint behaved differently
from the unit-tested runProjectHandoff helper.

The handoff sub now short-circuits before parseFlags / projectDaemonUrl,
so `od project handoff` runs exactly runProjectHandoff with no
intervening parsing. handoff-cli.test.ts gains unknown-flag and
missing-value cases covering the structured fail path.

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-20 13:28:27 +08:00

744 lines
29 KiB
TypeScript

// Persisted event shape under test is `PersistedAgentEvent` from
// packages/contracts/src/api/chat.ts (the discriminator is `kind`, the
// thinking field is `text`). The daemon's claude-stream emits a different
// `type:`-shaped wire format — those events are translated to the persisted
// `kind:` shape by the web client before being PUT back for storage.
//
// All seeded events here mirror the canonical persisted shape, exactly as
// they appear in `messages.events_json` in production databases.
//
// Note on fs imports: both this file and `transcript-export.ts` use
// `import fs from 'node:fs'` (default import — the CJS module exports
// object) so that `vi.spyOn(fs, '<fn>')` in the failure-injection tests can
// actually redefine properties. ESM namespace imports of `node:fs` (`import
// * as fs from 'node:fs'`) produce a frozen Module Namespace Object that
// `vi.spyOn` cannot mutate; default-import sidesteps that restriction
// because it returns the underlying CJS `module.exports` object.
import { afterEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
openDatabase,
upsertMessage,
} from '../src/db.js';
import {
exportProjectTranscript,
TranscriptExportLockedError,
} from '../src/transcript-export.js';
const PROJECT_ID = 'project-1';
const FIXED_NOW = () => new Date('2026-05-04T12:00:00.000Z');
let tempDir: string | null = null;
let projectsRoot: string | null = null;
afterEach(() => {
closeDatabase();
vi.restoreAllMocks();
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
projectsRoot = null;
});
type TranscriptLine = Record<string, unknown>;
type TranscriptLines = TranscriptLine[];
type PersistedAgentEvent =
| { kind: 'status'; label: string; detail?: string }
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool_use'; id: string; name: string; input: unknown }
| { kind: 'tool_result'; toolUseId: string; content: string; isError: boolean }
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
| { kind: 'raw'; line: string };
type ChatAttachment = { path: string; name: string; kind: string; size?: number };
type ChatCommentAttachment = {
id: string;
order: number;
filePath: string;
elementId: string;
selector: string;
label: string;
comment: string;
currentText: string;
pagePosition: { x: number; y: number };
htmlHint: string;
};
function line(lines: TranscriptLines, index: number): TranscriptLine {
const item = lines[index];
if (!item) throw new Error(`missing transcript line ${index}`);
return item;
}
function setup(opts: { skipMkdir?: boolean } = {}): { db: Database.Database; projectsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-tx-'));
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Project',
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
if (!opts.skipMkdir) {
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
}
return { db, projectsRoot };
}
function readLines(filePath: string): TranscriptLines {
const raw = fs.readFileSync(filePath, 'utf8');
expect(raw.endsWith('\n')).toBe(true);
return raw
.split('\n')
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as TranscriptLine) as TranscriptLines;
}
function seedConversation(db: Database.Database, opts: { id: string; createdAt: number; updatedAt?: number; title?: string | null }) {
insertConversation(db, {
id: opts.id,
projectId: PROJECT_ID,
title: opts.title ?? null,
createdAt: opts.createdAt,
updatedAt: opts.updatedAt ?? opts.createdAt,
});
}
function seedMessage(
db: Database.Database,
conversationId: string,
m: {
id: string;
role: 'user' | 'assistant';
content?: string;
events?: PersistedAgentEvent[];
attachments?: ChatAttachment[];
commentAttachments?: ChatCommentAttachment[];
},
) {
upsertMessage(db, conversationId, {
id: m.id,
role: m.role,
content: m.content ?? '',
events: m.events,
attachments: m.attachments,
commentAttachments: m.commentAttachments,
});
}
describe('exportProjectTranscript', () => {
it('writes a header-only file when the project has no conversations', () => {
const { db, projectsRoot } = setup();
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(result.conversationCount).toBe(0);
expect(result.messageCount).toBe(0);
expect(result.bytesWritten).toBeGreaterThan(0);
expect(result.path).toBe(path.join(projectsRoot, PROJECT_ID, '.transcript.jsonl'));
const lines = readLines(result.path);
expect(lines).toHaveLength(1);
expect(line(lines, 0)).toEqual({
kind: 'header',
schemaVersion: 2,
projectId: PROJECT_ID,
exportedAt: '2026-05-04T12:00:00.000Z',
conversationCount: 0,
messageCount: 0,
attachmentCount: 0,
commentAttachmentCount: 0,
attachmentsInlined: false,
});
});
it('emits header, conversation marker, and one message line per message', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100, title: 'Greeting' });
seedMessage(db, 'c1', {
id: 'm1',
role: 'user',
events: [{ kind: 'text', text: 'hello' }],
});
seedMessage(db, 'c1', {
id: 'm2',
role: 'assistant',
events: [{ kind: 'text', text: 'world' }],
});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(lines).toHaveLength(4);
expect(line(lines, 0).kind).toBe('header');
expect(line(lines, 0).schemaVersion).toBe(2);
expect(line(lines, 0).conversationCount).toBe(1);
expect(line(lines, 0).messageCount).toBe(2);
expect(line(lines, 1)).toEqual({
kind: 'conversation',
id: 'c1',
title: 'Greeting',
createdAt: 100,
updatedAt: expect.any(Number),
});
expect(line(lines, 2).kind).toBe('message');
expect(line(lines, 2).conversationId).toBe('c1');
expect(line(lines, 2).id).toBe('m1');
expect(line(lines, 2).role).toBe('user');
expect(line(lines, 2).position).toBe(0);
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'hello' }]);
expect(line(lines, 3).id).toBe('m2');
expect(line(lines, 3).position).toBe(1);
expect(line(lines, 3).blocks).toEqual([{ type: 'text', text: 'world' }]);
});
it('coalesces adjacent text events into a single text block', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'text', text: 'hel' },
{ kind: 'text', text: 'lo' },
{ kind: 'text', text: ' world' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const msg = line(lines, 2);
expect(msg.blocks).toEqual([{ type: 'text', text: 'hello world' }]);
});
it('preserves tool_use and tool_result ordering interleaved with text', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'text', text: 'I will read.' },
{ kind: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
{ kind: 'tool_result', toolUseId: 'tu_1', content: 'file contents', isError: false },
{ kind: 'text', text: ' Done.' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'text', text: 'I will read.' },
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
{ type: 'tool_result', toolUseId: 'tu_1', content: 'file contents', isError: false },
{ type: 'text', text: ' Done.' },
]);
});
it('drops status / usage / raw telemetry events without breaking content', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'status', label: 'streaming' },
{ kind: 'thinking', text: 'reasoning' },
{ kind: 'usage', inputTokens: 5 },
{ kind: 'text', text: 'answer' },
{ kind: 'raw', line: '??' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'reasoning' },
{ type: 'text', text: 'answer' },
]);
});
it('flushes accumulator on type change (thinking → text → tool)', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'thinking', text: 'plan' },
{ kind: 'text', text: 'ok' },
{ kind: 'tool_use', id: 't', name: 'X', input: {} },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'plan' },
{ type: 'text', text: 'ok' },
{ type: 'tool_use', id: 't', name: 'X', input: {} },
]);
});
it('emits text → thinking → text as three ordered blocks (arrival order, not heuristic)', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'text', text: 'pre' },
{ kind: 'thinking', text: 'mid' },
{ kind: 'text', text: 'post' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'text', text: 'pre' },
{ type: 'thinking', thinking: 'mid' },
{ type: 'text', text: 'post' },
]);
});
it('coalesces consecutive thinking events into one thinking block', () => {
// A continuous thinking run with no intervening boundary marker
// produces one block. Boundary-preservation across thinking-start
// markers is exercised in test #25 below.
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'thinking', text: 'first ' },
{ kind: 'thinking', text: 'second ' },
{ kind: 'thinking', text: 'third' },
{ kind: 'text', text: 'visible' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'first second third' },
{ type: 'text', text: 'visible' },
]);
});
it('orders multiple conversations chronologically by created_at (regardless of updated_at)', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'older', createdAt: 100, updatedAt: 999, title: 'Older' });
seedConversation(db, { id: 'newer', createdAt: 200, updatedAt: 200, title: 'Newer' });
seedMessage(db, 'older', { id: 'm-older', role: 'user', events: [{ kind: 'text', text: 'a' }] });
seedMessage(db, 'newer', { id: 'm-newer', role: 'user', events: [{ kind: 'text', text: 'b' }] });
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const conversationLines = lines.filter((l) => l.kind === 'conversation');
expect(conversationLines.map((c) => c.id)).toEqual(['older', 'newer']);
});
it('restricts the export to a single conversation when options.conversationId is set', () => {
// The handoff/resume flow scopes to one conversation — a project-wide
// export would blend unrelated chats. The unrelated conversation must
// not appear in the output at all.
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'keep', createdAt: 100, title: 'Keep' });
seedConversation(db, { id: 'drop', createdAt: 200, title: 'Drop' });
seedMessage(db, 'keep', { id: 'm-keep', role: 'user', events: [{ kind: 'text', text: 'KEEP-TEXT' }] });
seedMessage(db, 'drop', { id: 'm-drop', role: 'user', events: [{ kind: 'text', text: 'DROP-TEXT' }] });
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, {
now: FIXED_NOW,
conversationId: 'keep',
});
expect(result.conversationCount).toBe(1);
expect(result.messageCount).toBe(1);
const conversationLines = readLines(result.path).filter((l) => l.kind === 'conversation');
expect(conversationLines.map((c) => c.id)).toEqual(['keep']);
const raw = fs.readFileSync(result.path, 'utf8');
expect(raw).toContain('KEEP-TEXT');
expect(raw).not.toContain('DROP-TEXT');
});
it('atomic write: leaves no .tmp file at success and does not disturb unrelated tmp files', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
// Pre-existing orphan tmp file from a hypothetical prior failed run.
const orphan = path.join(projectsRoot, PROJECT_ID, '.transcript.jsonl.tmp.99999.deadbeef');
fs.writeFileSync(orphan, 'leftover');
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
const tmps = dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'));
// Only the orphan should remain — our run's tmp must have been renamed away.
expect(tmps).toEqual(['.transcript.jsonl.tmp.99999.deadbeef']);
expect(fs.readFileSync(orphan, 'utf8')).toBe('leftover');
expect(dirEntries).toContain('.transcript.jsonl');
});
it('falls back to messages.content as a single text block when events_json is null', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
// User-typed messages persist as plain text in `content`; events_json is
// null because the user input does not flow through the streaming pipeline.
upsertMessage(db, 'c1', {
id: 'm-user',
role: 'user',
content: 'Make me a landing page.',
// events deliberately omitted
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).id).toBe('m-user');
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'Make me a landing page.' }]);
});
it('prefers event-derived blocks over the content fallback when both are present', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
// Assistant rows in production carry a coalesced `content` AND the full
// `events` blocks. The event-derived blocks are richer (tool_use,
// thinking) so they must win.
upsertMessage(db, 'c1', {
id: 'm-asst',
role: 'assistant',
content: 'final coalesced text',
events: [
{ kind: 'text', text: 'final ' },
{ kind: 'text', text: 'coalesced text' },
{ kind: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'text', text: 'final coalesced text' },
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
]);
});
it('produces empty blocks (no throw) for messages with malformed events_json', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
// Bypass the helpers so we can inject a deliberately malformed value.
db.prepare(
`INSERT INTO messages (id, conversation_id, role, content, events_json, position, created_at)
VALUES ('mbad', 'c1', 'assistant', '', 'not json', 0, ${Date.now()})`,
).run();
// Suppress the now-emitted warning so test output stays clean.
vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(lines).toHaveLength(3); // header + conversation + 1 message
expect(line(lines, 2).id).toBe('mbad');
expect(line(lines, 2).blocks).toEqual([]);
});
it('rejects unsafe project ids (path-traversal guard from projectDir)', () => {
const { db, projectsRoot } = setup();
expect(() =>
exportProjectTranscript(db, projectsRoot, '../etc', { now: FIXED_NOW }),
).toThrow(/invalid project id/);
});
// ---------- §1.8 atomic-write failure injection (tests #15-#17) ----------
it('cleans up tmp file when writeFileSync throws', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
const realWrite = fs.writeFileSync;
vi.spyOn(fs, 'writeFileSync').mockImplementation((p: any, ...rest: any[]) => {
// Fail only on the transcript tmp write. Other writes (e.g. test
// fixtures) must continue to work.
if (typeof p === 'string' && p.includes('.transcript.jsonl.tmp.')) {
throw new Error('disk full');
}
return (realWrite as any)(p, ...rest);
});
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(/disk full/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('.transcript.jsonl');
// Lock should also have been released.
expect(dirEntries).not.toContain('.transcript.lock');
});
it('cleans up tmp file when fsyncSync throws', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
vi.spyOn(fs, 'fsyncSync').mockImplementation(() => {
throw new Error('fsync failed');
});
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(/fsync failed/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('.transcript.jsonl');
expect(dirEntries).not.toContain('.transcript.lock');
});
it('cleans up tmp file when renameSync throws', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
throw new Error('rename failed');
});
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(/rename failed/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('.transcript.jsonl');
expect(dirEntries).not.toContain('.transcript.lock');
});
// ---------- §1.8 existing-file replacement (test #18) ----------
it('replaces existing transcript file on second export', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
// First export.
const result1 = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const finalPath = result1.path;
// Inject a sentinel — a downstream consumer / older transcript.
fs.writeFileSync(finalPath, '{"sentinel":true}\n');
expect(fs.readFileSync(finalPath, 'utf8')).toContain('sentinel');
// Second export should atomically replace the sentinel.
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const after = fs.readFileSync(finalPath, 'utf8');
expect(after).not.toContain('sentinel');
const lines = after.split('\n').filter((l) => l.length > 0).map((l) => JSON.parse(l));
expect(line(lines, 0).kind).toBe('header');
expect(line(lines, 2).id).toBe('m1');
});
// ---------- §1.5 lock contention (test #19, advisor-redesigned) ----------
it('throws TranscriptExportLockedError when lock held; succeeds after unlink', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
const lockPath = path.join(projectsRoot, PROJECT_ID, '.transcript.lock');
const finalPath = path.join(projectsRoot, PROJECT_ID, '.transcript.jsonl');
// Pre-create the lock to simulate a concurrent export in flight.
fs.writeFileSync(lockPath, '');
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(TranscriptExportLockedError);
// No transcript should have been written while the lock was held.
expect(fs.existsSync(finalPath)).toBe(false);
// Release the lock — a subsequent export must succeed.
fs.unlinkSync(lockPath);
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(result.path).toBe(finalPath);
expect(fs.existsSync(finalPath)).toBe(true);
expect(fs.existsSync(lockPath)).toBe(false);
});
// ---------- §1.3 parse-warning surface (tests #20-#21) ----------
it('warns when events_json is malformed JSON and falls back to content', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
db.prepare(
`INSERT INTO messages (id, conversation_id, role, content, events_json, position, created_at)
VALUES ('mmal', 'c1', 'assistant', 'fallback content', '{not valid', 0, ${Date.now()})`,
).run();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(warn).toHaveBeenCalledTimes(1);
const warning = warn.mock.calls[0]?.[0];
expect(warning).toContain('mmal');
expect(warning).toContain(PROJECT_ID);
expect(warning).toContain('malformed');
const lines = readLines(result.path);
expect(line(lines, 2).id).toBe('mmal');
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
});
it('warns when events_json is JSON but not an array', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
db.prepare(
`INSERT INTO messages (id, conversation_id, role, content, events_json, position, created_at)
VALUES ('mobj', 'c1', 'assistant', 'fallback content', '{"foo":1}', 0, ${Date.now()})`,
).run();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(warn).toHaveBeenCalledTimes(1);
const warning = warn.mock.calls[0]?.[0];
expect(warning).toContain('mobj');
expect(warning).toContain('not_array');
const lines = readLines(result.path);
expect(line(lines, 2).blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
});
// ---------- §1.6 attachments (tests #22-#23) ----------
it('header carries attachmentCount + commentAttachmentCount totals', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'user',
events: [{ kind: 'text', text: 'a' }],
attachments: [
{ path: 'a.png', name: 'a.png', kind: 'image', size: 100 },
{ path: 'b.png', name: 'b.png', kind: 'image', size: 200 },
],
commentAttachments: [
{
id: 'ca1',
order: 0,
filePath: 'p.html',
elementId: 'e1',
selector: '#x',
label: 'L',
comment: 'C',
currentText: '',
pagePosition: { x: 0, y: 0 },
htmlHint: '',
},
],
});
seedMessage(db, 'c1', {
id: 'm2',
role: 'user',
events: [{ kind: 'text', text: 'b' }],
attachments: [{ path: 'c.png', name: 'c.png', kind: 'image' }],
});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(line(lines, 0).attachmentCount).toBe(3);
expect(line(lines, 0).commentAttachmentCount).toBe(1);
expect(line(lines, 0).attachmentsInlined).toBe(false);
});
it('per-message line carries attachments / commentAttachments only when present', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm-with',
role: 'user',
events: [{ kind: 'text', text: 'q' }],
attachments: [{ path: 'a.png', name: 'a.png', kind: 'image', size: 99 }],
commentAttachments: [
{
id: 'ca1',
order: 0,
filePath: 'p.html',
elementId: 'e1',
selector: '#x',
label: 'Lab',
comment: 'Cmt',
currentText: '',
pagePosition: { x: 1, y: 2 },
htmlHint: '',
},
],
});
seedMessage(db, 'c1', {
id: 'm-bare',
role: 'user',
events: [{ kind: 'text', text: 'r' }],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const withAtt = lines.find((l) => l.id === 'm-with');
const bare = lines.find((l) => l.id === 'm-bare');
if (!withAtt) throw new Error('m-with transcript line not found');
if (!bare) throw new Error('m-bare transcript line not found');
expect(withAtt.attachments).toEqual([
{ path: 'a.png', name: 'a.png', kind: 'image', size: 99 },
]);
expect(withAtt.commentAttachments).toEqual([
{ id: 'ca1', filePath: 'p.html', label: 'Lab', comment: 'Cmt' },
]);
expect(bare.attachments).toBeUndefined();
expect(bare.commentAttachments).toBeUndefined();
});
// ---------- §1.7 missing project directory (test #24) ----------
it('creates project directory if it does not exist on disk', () => {
const { db, projectsRoot } = setup({ skipMkdir: true });
expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID))).toBe(false);
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(fs.existsSync(result.path)).toBe(true);
const lines = readLines(result.path);
expect(line(lines, 0).kind).toBe('header');
expect(line(lines, 2).id).toBe('m1');
});
// ---------- Codex P2 (3188524878): thinking-start boundary preservation ----------
it('flushes thinking accumulator on status thinking-start marker so adjacent segments stay separate', () => {
// The web translator emits `{ kind: 'status', label: 'thinking' }` at
// every thinking_start (apps/web/src/providers/daemon.ts:367-369).
// Two thinking segments separated only by that marker must stay as two
// blocks; merging them would lose the original boundary and make the
// transcript non-lossless for synthesis.
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ kind: 'thinking', text: 'a' },
{ kind: 'thinking', text: 'b' },
{ kind: 'status', label: 'thinking' },
{ kind: 'thinking', text: 'c' },
{ kind: 'thinking', text: 'd' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(line(lines, 2).blocks).toEqual([
{ type: 'thinking', thinking: 'ab' },
{ type: 'thinking', thinking: 'cd' },
]);
});
});