mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-10 01:33:04 +08:00
fix(daemon): address PR #493 review feedback for transcript export
Addresses every blocker, P2, and P3 raised on https://github.com/nexu-io/open-design/pull/493: - Blocker (event-shape mismatch): coalescer now switches on the PersistedAgentEvent kind discriminator (text/thinking/tool_use/ tool_result), reading the shared `text` field for content kinds, matching what apps/web/src/providers/daemon.ts:347-394 actually persists into messages.events_json. Empirical confirmation: live SQLite contains zero `type` keys. - Removed @ts-nocheck from the source file; added inline types for Db (Database.Database), ConversationRow, MessageRow, AttachmentRef, CommentAttachmentRef, and Block. Tests retain @ts-nocheck per codebase convention. Note: db.ts still uses @ts-nocheck, so the new types catch drift inside transcript-export.ts itself, not at the SQLite-helper boundary. - parseEvents now distinguishes null / malformed / not_array / ok cases; non-null-but-unparseable rows emit a console.warn with project+message id before falling back to content. - Switched temp-write from writeSync (which can return short) to writeFileSync({flag:'wx'}); explicit fsync via reopen before rename, per reviewer concern about partial-write durability. - Added per-project lockfile (.transcript.lock) acquired with openSync(..., 'wx') and released in finally; concurrent exports throw the new TranscriptExportLockedError. Stale-lock recovery is documented as a known limitation in the file header. - Header gains attachmentCount, commentAttachmentCount, and explicit attachmentsInlined: false. Per-message lines gain attachments / commentAttachments references (paths only, not bytes; synthesis reads files from disk by path). schemaVersion bumped 1 -> 2 so the change is explicit; v1 was never consumed. - mkdirSync(dir, { recursive: true }) at entry covers projects with DB rows but no on-disk directory yet (codex bot finding). - Refactored node:fs imports from named to default (import fs from 'node:fs') so vitest spies in tests #15-#17 can redefine properties on the underlying CJS exports object. ESM namespace imports of node:fs produce a frozen Module Namespace Object that vi.spyOn cannot mutate; default-import returns the CJS module.exports which is mutable. - Inline PersistedAgentEvent union: the daemon tsconfig does not resolve the `@open-design/contracts/api/chat` subpath export, so the union is restated in the source. Schema-mismatch tests cover the case where the contract would diverge. - Test count 14 -> 24: failure injection for writeFileSync / fsyncSync / renameSync, existing-file replacement, lockfile contention (lockfile-pre-create design — synchronous API can't race via Promise.allSettled), parse-warning cases (malformed + not-array), attachments header + per-message coverage, missing- project-dir case. Refs nexu-io/open-design#450 (does not close).
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
// One-shot dump of a project's conversation history to disk in a structured,
|
||||
// LLM-friendly JSON Lines file at <projectDir>/.transcript.jsonl.
|
||||
//
|
||||
@@ -13,33 +12,107 @@
|
||||
// and `jq -c`/`tail`-friendly. A `schemaVersion` field on the header reserves
|
||||
// room for incompatible changes later.
|
||||
//
|
||||
// Coalescing: events_json carries streaming `text_delta` / `thinking_delta`
|
||||
// chunks plus tool_use / tool_result / thinking_start markers and telemetry
|
||||
// (status / usage / raw). The export collapses runs of same-type deltas into
|
||||
// terminal `text` / `thinking` blocks via arrival-order with type-change
|
||||
// flush, preserving any interleaving with tool blocks. Telemetry is dropped.
|
||||
// Persisted event shape: see `packages/contracts/src/api/chat.ts` →
|
||||
// `PersistedAgentEvent` (the discriminator field is `kind`, NOT `type`). The
|
||||
// daemon's claude-stream emits a `type:`-shaped wire format; the web app
|
||||
// translates those into `kind:`-shaped AgentEvents before PUTting them back
|
||||
// to be persisted. The export reads what is actually on disk, so it speaks
|
||||
// the `kind:` shape.
|
||||
//
|
||||
// Coalescing rules:
|
||||
// * `kind: 'text'` runs concatenate their `text` field into one terminal
|
||||
// text block.
|
||||
// * `kind: 'thinking'` runs concatenate their `text` field into one
|
||||
// terminal thinking block (the field is `text`, not `thinking` — see
|
||||
// contract above).
|
||||
// * `kind: 'tool_use'` and `kind: 'tool_result'` flush any pending text /
|
||||
// thinking accumulator and emit verbatim.
|
||||
// * `kind: 'status' | 'usage' | 'raw'` drop (telemetry / parser fallback).
|
||||
// * Type-change between text ↔ thinking flushes the prior accumulator.
|
||||
//
|
||||
// Content fallback: user-typed messages persist as plain text in
|
||||
// `messages.content` with events_json = NULL (the user didn't go through the
|
||||
// streaming-event pipeline). When event-derived blocks come back empty we
|
||||
// fall back to a single text block from content so a typed prompt is not
|
||||
// silently lost. attachments_json / comment_attachments_json are out of
|
||||
// scope for this PR — call out as a known omission to revisit.
|
||||
// `messages.content` with events_json = NULL (the user input never flowed
|
||||
// through the streaming-event pipeline). When event-derived blocks come back
|
||||
// empty we fall back to a single text block from content so a typed prompt
|
||||
// is not silently lost.
|
||||
//
|
||||
// Attachments handling: the per-message `attachments_json` and
|
||||
// `comment_attachments_json` columns are surfaced as references (path/name/
|
||||
// kind/size, NOT inlined bytes). The header carries `attachmentCount`,
|
||||
// `commentAttachmentCount`, and an explicit `attachmentsInlined: false`
|
||||
// signal so a synthesis consumer can distinguish a complete transcript from
|
||||
// one with silently omitted inputs.
|
||||
//
|
||||
// Concurrency: a per-project lockfile (`.transcript.lock`) is acquired with
|
||||
// `openSync(..., 'wx')` and released in `finally`. A second concurrent
|
||||
// export throws `TranscriptExportLockedError`. Stale-lock recovery (e.g.
|
||||
// after a crash) is out of scope; the operator can clear the file manually
|
||||
// via `rm .od/projects/<id>/.transcript.lock`.
|
||||
|
||||
import {
|
||||
closeSync,
|
||||
fsyncSync,
|
||||
openSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { projectDir } from './projects.js';
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SCHEMA_VERSION = 2;
|
||||
const TRANSCRIPT_FILENAME = '.transcript.jsonl';
|
||||
const LOCK_FILENAME = '.transcript.lock';
|
||||
|
||||
// Inline copy of the PersistedAgentEvent discriminated union from
|
||||
// `packages/contracts/src/api/chat.ts`. The daemon tsconfig does not resolve
|
||||
// the `./api/chat` subpath export, so the union is restated here. Kept
|
||||
// structurally identical to the contract; if the contract diverges, this
|
||||
// file will fail behaviorally first (events drop into the default branch)
|
||||
// and the schema-mismatch tests will catch it.
|
||||
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 Db = Database.Database;
|
||||
|
||||
interface ConversationRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string | null;
|
||||
position: number;
|
||||
eventsJson: string | null;
|
||||
createdAt: number;
|
||||
attachmentsJson: string | null;
|
||||
commentAttachmentsJson: string | null;
|
||||
}
|
||||
|
||||
interface AttachmentRef {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: 'image' | 'file';
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface CommentAttachmentRef {
|
||||
id: string;
|
||||
filePath: string;
|
||||
label: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
type Block =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'thinking'; thinking: string }
|
||||
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
||||
| { type: 'tool_result'; toolUseId: string; content: string; isError: boolean };
|
||||
|
||||
export interface TranscriptExportOptions {
|
||||
now?: () => Date;
|
||||
@@ -52,137 +125,282 @@ export interface TranscriptExportResult {
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
export class TranscriptExportLockedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TranscriptExportLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
export function exportProjectTranscript(
|
||||
db,
|
||||
db: Db,
|
||||
projectsRoot: string,
|
||||
projectId: string,
|
||||
options: TranscriptExportOptions = {},
|
||||
): TranscriptExportResult {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
// The project may have DB rows but no on-disk directory yet (a synthesis
|
||||
// caller can hit this immediately after `insertProject`). mkdirSync with
|
||||
// recursive is idempotent; cheaper than guarding via existsSync.
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const finalPath = path.join(dir, TRANSCRIPT_FILENAME);
|
||||
const tmpPath = path.join(
|
||||
dir,
|
||||
`${TRANSCRIPT_FILENAME}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`,
|
||||
);
|
||||
const lockPath = path.join(dir, LOCK_FILENAME);
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
// Conversations ordered chronologically (oldest first) — easiest for an LLM
|
||||
// to follow as a single sequence. db.listConversations sorts by updated_at
|
||||
// DESC for the sidebar; we re-sort here.
|
||||
const conversations = db
|
||||
.prepare(
|
||||
`SELECT id, title, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM conversations
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at ASC`,
|
||||
)
|
||||
.all(projectId);
|
||||
|
||||
const messageStmt = db.prepare(
|
||||
`SELECT id, role, content, position, events_json AS eventsJson,
|
||||
created_at AS createdAt
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY position ASC`,
|
||||
);
|
||||
|
||||
// Build the body in two passes: first count messages so the header has the
|
||||
// right total, then emit header → for each conversation { marker → messages }.
|
||||
const bodyParts: { conv: any; messages: any[] }[] = [];
|
||||
let messageCount = 0;
|
||||
for (const conv of conversations) {
|
||||
const messages = messageStmt.all(conv.id).map((row) => {
|
||||
const blocks = coalesceBlocks(parseEvents(row.eventsJson));
|
||||
if (blocks.length === 0 && typeof row.content === 'string' && row.content.length > 0) {
|
||||
blocks.push({ type: 'text', text: row.content });
|
||||
}
|
||||
return {
|
||||
kind: 'message',
|
||||
conversationId: conv.id,
|
||||
id: row.id,
|
||||
role: row.role,
|
||||
position: Number(row.position),
|
||||
createdAt: Number(row.createdAt),
|
||||
blocks,
|
||||
};
|
||||
});
|
||||
messageCount += messages.length;
|
||||
bodyParts.push({ conv, messages });
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
JSON.stringify({
|
||||
kind: 'header',
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
projectId,
|
||||
exportedAt: now().toISOString(),
|
||||
conversationCount: conversations.length,
|
||||
messageCount,
|
||||
}),
|
||||
];
|
||||
for (const { conv, messages } of bodyParts) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
kind: 'conversation',
|
||||
id: conv.id,
|
||||
title: conv.title ?? null,
|
||||
createdAt: Number(conv.createdAt),
|
||||
updatedAt: Number(conv.updatedAt),
|
||||
}),
|
||||
);
|
||||
for (const m of messages) lines.push(JSON.stringify(m));
|
||||
}
|
||||
|
||||
const encoded = Buffer.from(lines.join('\n') + '\n', 'utf8');
|
||||
|
||||
let fd: number | null = null;
|
||||
let lockFd: number | null = null;
|
||||
try {
|
||||
fd = openSync(tmpPath, 'wx');
|
||||
writeSync(fd, encoded, 0, encoded.length, 0);
|
||||
fsyncSync(fd);
|
||||
closeSync(fd);
|
||||
fd = null;
|
||||
renameSync(tmpPath, finalPath);
|
||||
} catch (err) {
|
||||
if (fd !== null) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// ignore close-after-error
|
||||
}
|
||||
}
|
||||
try {
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// tmp may not exist if openSync threw
|
||||
lockFd = fs.openSync(lockPath, 'wx');
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'EEXIST') {
|
||||
throw new TranscriptExportLockedError(
|
||||
`transcript export for project ${projectId} is already in progress`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
path: finalPath,
|
||||
conversationCount: conversations.length,
|
||||
messageCount,
|
||||
bytesWritten: encoded.length,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvents(raw): unknown[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
return [];
|
||||
// Conversations ordered chronologically (oldest first) — easiest for an
|
||||
// LLM to follow as a single sequence. db.listConversations sorts by
|
||||
// updated_at DESC for the sidebar; we re-sort here.
|
||||
const conversations = db
|
||||
.prepare(
|
||||
`SELECT id, title, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM conversations
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at ASC`,
|
||||
)
|
||||
.all(projectId) as ConversationRow[];
|
||||
|
||||
const messageStmt = db.prepare(
|
||||
`SELECT id, role, content, position,
|
||||
events_json AS eventsJson,
|
||||
created_at AS createdAt,
|
||||
attachments_json AS attachmentsJson,
|
||||
comment_attachments_json AS commentAttachmentsJson
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY position ASC`,
|
||||
);
|
||||
|
||||
// Build the body in two passes: first build messages so the header has
|
||||
// the right totals, then emit header → for each conversation { marker →
|
||||
// messages }.
|
||||
interface BuiltMessage {
|
||||
kind: 'message';
|
||||
conversationId: string;
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
position: number;
|
||||
createdAt: number;
|
||||
blocks: Block[];
|
||||
attachments?: AttachmentRef[];
|
||||
commentAttachments?: CommentAttachmentRef[];
|
||||
}
|
||||
|
||||
const bodyParts: { conv: ConversationRow; messages: BuiltMessage[] }[] = [];
|
||||
let messageCount = 0;
|
||||
let attachmentCount = 0;
|
||||
let commentAttachmentCount = 0;
|
||||
|
||||
for (const conv of conversations) {
|
||||
const rows = messageStmt.all(conv.id) as MessageRow[];
|
||||
const messages: BuiltMessage[] = rows.map((row) => {
|
||||
const parsed = parseEvents(row.eventsJson);
|
||||
if (parsed.reason === 'malformed' || parsed.reason === 'not_array') {
|
||||
// Surface a data-quality signal on stderr so corrupted rows are
|
||||
// visible in daemon logs. Best-effort fallback to content still
|
||||
// fires; the export must remain a one-shot best-effort dump
|
||||
// rather than aborting on a single bad row.
|
||||
console.warn(
|
||||
`[transcript-export] message ${row.id} (project ${projectId}): ` +
|
||||
`events_json is non-null but ${parsed.reason}; falling back to content.`,
|
||||
);
|
||||
}
|
||||
const blocks = coalesceBlocks(parsed.events);
|
||||
if (blocks.length === 0 && typeof row.content === 'string' && row.content.length > 0) {
|
||||
blocks.push({ type: 'text', text: row.content });
|
||||
}
|
||||
|
||||
const attachments = parseAttachments(row.attachmentsJson);
|
||||
const commentAttachments = parseCommentAttachments(row.commentAttachmentsJson);
|
||||
attachmentCount += attachments.length;
|
||||
commentAttachmentCount += commentAttachments.length;
|
||||
|
||||
const built: BuiltMessage = {
|
||||
kind: 'message',
|
||||
conversationId: conv.id,
|
||||
id: row.id,
|
||||
role: row.role,
|
||||
position: Number(row.position),
|
||||
createdAt: Number(row.createdAt),
|
||||
blocks,
|
||||
};
|
||||
if (attachments.length > 0) built.attachments = attachments;
|
||||
if (commentAttachments.length > 0) built.commentAttachments = commentAttachments;
|
||||
return built;
|
||||
});
|
||||
messageCount += messages.length;
|
||||
bodyParts.push({ conv, messages });
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
JSON.stringify({
|
||||
kind: 'header',
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
projectId,
|
||||
exportedAt: now().toISOString(),
|
||||
conversationCount: conversations.length,
|
||||
messageCount,
|
||||
attachmentCount,
|
||||
commentAttachmentCount,
|
||||
// Explicit signal: attachment metadata is referenced by path; the
|
||||
// bytes themselves remain on disk under the project directory and
|
||||
// are not inlined into the transcript.
|
||||
attachmentsInlined: false,
|
||||
}),
|
||||
];
|
||||
for (const { conv, messages } of bodyParts) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
kind: 'conversation',
|
||||
id: conv.id,
|
||||
title: conv.title ?? null,
|
||||
createdAt: Number(conv.createdAt),
|
||||
updatedAt: Number(conv.updatedAt),
|
||||
}),
|
||||
);
|
||||
for (const m of messages) lines.push(JSON.stringify(m));
|
||||
}
|
||||
|
||||
const encoded = Buffer.from(lines.join('\n') + '\n', 'utf8');
|
||||
|
||||
// Atomic write: writeFileSync with the 'wx' flag loops internally until
|
||||
// the entire buffer is written or it throws. We then reopen the file
|
||||
// just to fsync data to disk before the rename, addressing the partial-
|
||||
// write durability concern flagged in review.
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, encoded, { flag: 'wx' });
|
||||
const fsyncFd = fs.openSync(tmpPath, 'r+');
|
||||
try {
|
||||
fs.fsyncSync(fsyncFd);
|
||||
} finally {
|
||||
fs.closeSync(fsyncFd);
|
||||
}
|
||||
fs.renameSync(tmpPath, finalPath);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// tmp may not exist if writeFileSync threw before creating it
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
path: finalPath,
|
||||
conversationCount: conversations.length,
|
||||
messageCount,
|
||||
bytesWritten: encoded.length,
|
||||
};
|
||||
} finally {
|
||||
if (lockFd !== null) {
|
||||
try {
|
||||
fs.closeSync(lockFd);
|
||||
} catch {
|
||||
// ignore close-after-error
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
// lock may already be gone if the disk vanished; not fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvents(raw: string | null): {
|
||||
events: PersistedAgentEvent[];
|
||||
reason: 'ok' | 'null' | 'malformed' | 'not_array';
|
||||
} {
|
||||
if (raw == null) return { events: [], reason: 'null' };
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(raw);
|
||||
} catch {
|
||||
return { events: [], reason: 'malformed' };
|
||||
}
|
||||
if (!Array.isArray(v)) return { events: [], reason: 'not_array' };
|
||||
return { events: v as PersistedAgentEvent[], reason: 'ok' };
|
||||
}
|
||||
|
||||
function parseAttachments(raw: string | null): AttachmentRef[] {
|
||||
if (raw == null) return [];
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(v)) return [];
|
||||
const out: AttachmentRef[] = [];
|
||||
for (const item of v) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as Record<string, unknown>;
|
||||
if (typeof a.path !== 'string' || typeof a.name !== 'string') continue;
|
||||
if (a.kind !== 'image' && a.kind !== 'file') continue;
|
||||
const ref: AttachmentRef = {
|
||||
path: a.path,
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
};
|
||||
if (typeof a.size === 'number') ref.size = a.size;
|
||||
out.push(ref);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseCommentAttachments(raw: string | null): CommentAttachmentRef[] {
|
||||
if (raw == null) return [];
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(v)) return [];
|
||||
const out: CommentAttachmentRef[] = [];
|
||||
for (const item of v) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const c = item as Record<string, unknown>;
|
||||
if (typeof c.id !== 'string') continue;
|
||||
if (typeof c.filePath !== 'string') continue;
|
||||
out.push({
|
||||
id: c.id,
|
||||
filePath: c.filePath,
|
||||
label: typeof c.label === 'string' ? c.label : '',
|
||||
comment: typeof c.comment === 'string' ? c.comment : '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Walk arrival-order. Maintain a single accumulator for the current run of
|
||||
// text or thinking deltas; flush on type change, on thinking_start, on any
|
||||
// tool block, and at end-of-stream. Telemetry events drop without flushing
|
||||
// (they neither contribute content nor signal a content boundary).
|
||||
function coalesceBlocks(events: any[]) {
|
||||
const blocks: any[] = [];
|
||||
// text or thinking events; flush on type change, on any tool block, and at
|
||||
// end-of-stream. Telemetry events drop without flushing (they neither
|
||||
// contribute content nor signal a content boundary).
|
||||
//
|
||||
// Both `kind: 'text'` and `kind: 'thinking'` carry their content in a `text`
|
||||
// field per PersistedAgentEvent; the output blocks rename thinking's field
|
||||
// to `thinking` so a downstream consumer can tell text apart from thinking
|
||||
// without consulting `type`.
|
||||
function coalesceBlocks(events: PersistedAgentEvent[]): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
let active: 'text' | 'thinking' | null = null;
|
||||
let buf = '';
|
||||
|
||||
@@ -198,27 +416,23 @@ function coalesceBlocks(events: any[]) {
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev || typeof ev !== 'object') continue;
|
||||
switch (ev.type) {
|
||||
case 'text_delta': {
|
||||
if (typeof ev.delta !== 'string') break;
|
||||
switch (ev.kind) {
|
||||
case 'text': {
|
||||
if (typeof ev.text !== 'string') break;
|
||||
if (active !== 'text') {
|
||||
flush();
|
||||
active = 'text';
|
||||
}
|
||||
buf += ev.delta;
|
||||
buf += ev.text;
|
||||
break;
|
||||
}
|
||||
case 'thinking_delta': {
|
||||
if (typeof ev.delta !== 'string') break;
|
||||
case 'thinking': {
|
||||
if (typeof ev.text !== 'string') break;
|
||||
if (active !== 'thinking') {
|
||||
flush();
|
||||
active = 'thinking';
|
||||
}
|
||||
buf += ev.delta;
|
||||
break;
|
||||
}
|
||||
case 'thinking_start': {
|
||||
flush();
|
||||
buf += ev.text;
|
||||
break;
|
||||
}
|
||||
case 'tool_use': {
|
||||
@@ -227,7 +441,7 @@ function coalesceBlocks(events: any[]) {
|
||||
type: 'tool_use',
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
input: ev.input ?? {},
|
||||
input: ev.input ?? null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -236,12 +450,15 @@ function coalesceBlocks(events: any[]) {
|
||||
blocks.push({
|
||||
type: 'tool_result',
|
||||
toolUseId: ev.toolUseId,
|
||||
content: ev.content,
|
||||
content: typeof ev.content === 'string' ? ev.content : String(ev.content ?? ''),
|
||||
isError: Boolean(ev.isError),
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Telemetry: status, usage, raw — intentional drop.
|
||||
// Telemetry: status, usage, raw — intentional drop. status with
|
||||
// label === 'thinking' is the daemon's translated thinking_start
|
||||
// marker; we don't use it as a flush trigger because adjacent
|
||||
// thinking blocks merge harmlessly for an LLM consumer.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// @ts-nocheck
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
// 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 fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -10,7 +27,10 @@ import {
|
||||
openDatabase,
|
||||
upsertMessage,
|
||||
} from '../src/db.js';
|
||||
import { exportProjectTranscript } from '../src/transcript-export.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');
|
||||
@@ -20,12 +40,13 @@ let projectsRoot: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
vi.restoreAllMocks();
|
||||
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
projectsRoot = null;
|
||||
});
|
||||
|
||||
function setup(): { db: any; projectsRoot: string } {
|
||||
function setup(opts: { skipMkdir?: boolean } = {}): { db: any; projectsRoot: string } {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-tx-'));
|
||||
const db = openDatabase(tempDir);
|
||||
insertProject(db, {
|
||||
@@ -35,7 +56,9 @@ function setup(): { db: any; projectsRoot: string } {
|
||||
updatedAt: 1,
|
||||
});
|
||||
projectsRoot = path.join(tempDir, 'projects');
|
||||
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
|
||||
if (!opts.skipMkdir) {
|
||||
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
|
||||
}
|
||||
return { db, projectsRoot };
|
||||
}
|
||||
|
||||
@@ -61,13 +84,22 @@ function seedConversation(db: any, opts: { id: string; createdAt: number; update
|
||||
function seedMessage(
|
||||
db: any,
|
||||
conversationId: string,
|
||||
m: { id: string; role: 'user' | 'assistant'; content?: string; events?: any[] },
|
||||
m: {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content?: string;
|
||||
events?: any[];
|
||||
attachments?: any[];
|
||||
commentAttachments?: any[];
|
||||
},
|
||||
) {
|
||||
upsertMessage(db, conversationId, {
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content ?? '',
|
||||
events: m.events,
|
||||
attachments: m.attachments,
|
||||
commentAttachments: m.commentAttachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,11 +117,14 @@ describe('exportProjectTranscript', () => {
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toEqual({
|
||||
kind: 'header',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
projectId: PROJECT_ID,
|
||||
exportedAt: '2026-05-04T12:00:00.000Z',
|
||||
conversationCount: 0,
|
||||
messageCount: 0,
|
||||
attachmentCount: 0,
|
||||
commentAttachmentCount: 0,
|
||||
attachmentsInlined: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,12 +134,12 @@ describe('exportProjectTranscript', () => {
|
||||
seedMessage(db, 'c1', {
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
events: [{ type: 'text_delta', delta: 'hello' }],
|
||||
events: [{ kind: 'text', text: 'hello' }],
|
||||
});
|
||||
seedMessage(db, 'c1', {
|
||||
id: 'm2',
|
||||
role: 'assistant',
|
||||
events: [{ type: 'text_delta', delta: 'world' }],
|
||||
events: [{ kind: 'text', text: 'world' }],
|
||||
});
|
||||
|
||||
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
|
||||
@@ -112,6 +147,7 @@ describe('exportProjectTranscript', () => {
|
||||
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[0].kind).toBe('header');
|
||||
expect(lines[0].schemaVersion).toBe(2);
|
||||
expect(lines[0].conversationCount).toBe(1);
|
||||
expect(lines[0].messageCount).toBe(2);
|
||||
expect(lines[1]).toEqual({
|
||||
@@ -132,16 +168,16 @@ describe('exportProjectTranscript', () => {
|
||||
expect(lines[3].blocks).toEqual([{ type: 'text', text: 'world' }]);
|
||||
});
|
||||
|
||||
it('coalesces adjacent text_delta chunks into a single text block', () => {
|
||||
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: [
|
||||
{ type: 'text_delta', delta: 'hel' },
|
||||
{ type: 'text_delta', delta: 'lo' },
|
||||
{ type: 'text_delta', delta: ' world' },
|
||||
{ kind: 'text', text: 'hel' },
|
||||
{ kind: 'text', text: 'lo' },
|
||||
{ kind: 'text', text: ' world' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -157,10 +193,10 @@ describe('exportProjectTranscript', () => {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
events: [
|
||||
{ type: 'text_delta', delta: '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_delta', delta: ' Done.' },
|
||||
{ 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.' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -180,11 +216,11 @@ describe('exportProjectTranscript', () => {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
events: [
|
||||
{ type: 'status', label: 'streaming' },
|
||||
{ type: 'thinking_delta', delta: 'reasoning' },
|
||||
{ type: 'usage', usage: { input_tokens: 5 } },
|
||||
{ type: 'text_delta', delta: 'answer' },
|
||||
{ type: 'raw', line: '??' },
|
||||
{ kind: 'status', label: 'streaming' },
|
||||
{ kind: 'thinking', text: 'reasoning' },
|
||||
{ kind: 'usage', inputTokens: 5 },
|
||||
{ kind: 'text', text: 'answer' },
|
||||
{ kind: 'raw', line: '??' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -202,9 +238,9 @@ describe('exportProjectTranscript', () => {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
events: [
|
||||
{ type: 'thinking_delta', delta: 'plan' },
|
||||
{ type: 'text_delta', delta: 'ok' },
|
||||
{ type: 'tool_use', id: 't', name: 'X', input: {} },
|
||||
{ kind: 'thinking', text: 'plan' },
|
||||
{ kind: 'text', text: 'ok' },
|
||||
{ kind: 'tool_use', id: 't', name: 'X', input: {} },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -223,9 +259,9 @@ describe('exportProjectTranscript', () => {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
events: [
|
||||
{ type: 'text_delta', delta: 'pre' },
|
||||
{ type: 'thinking_delta', delta: 'mid' },
|
||||
{ type: 'text_delta', delta: 'post' },
|
||||
{ kind: 'text', text: 'pre' },
|
||||
{ kind: 'thinking', text: 'mid' },
|
||||
{ kind: 'text', text: 'post' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -237,25 +273,26 @@ describe('exportProjectTranscript', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats thinking_start as a flush trigger so multi-block thinking survives', () => {
|
||||
it('coalesces consecutive thinking events into one thinking block', () => {
|
||||
// The persisted shape does not carry a separate thinking_start signal —
|
||||
// a continuous thinking run produces one block. Adjacent thinking-only
|
||||
// runs merge harmlessly for an LLM consumer (acceptable today).
|
||||
const { db, projectsRoot } = setup();
|
||||
seedConversation(db, { id: 'c1', createdAt: 100 });
|
||||
seedMessage(db, 'c1', {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
events: [
|
||||
{ type: 'thinking_start' },
|
||||
{ type: 'thinking_delta', delta: 'first' },
|
||||
{ type: 'thinking_start' },
|
||||
{ type: 'thinking_delta', delta: 'second' },
|
||||
{ type: 'text_delta', delta: 'visible' },
|
||||
{ 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(lines[2].blocks).toEqual([
|
||||
{ type: 'thinking', thinking: 'first' },
|
||||
{ type: 'thinking', thinking: 'second' },
|
||||
{ type: 'thinking', thinking: 'first second third' },
|
||||
{ type: 'text', text: 'visible' },
|
||||
]);
|
||||
});
|
||||
@@ -264,8 +301,8 @@ describe('exportProjectTranscript', () => {
|
||||
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: [{ type: 'text_delta', delta: 'a' }] });
|
||||
seedMessage(db, 'newer', { id: 'm-newer', role: 'user', events: [{ type: 'text_delta', delta: 'b' }] });
|
||||
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');
|
||||
@@ -275,7 +312,7 @@ describe('exportProjectTranscript', () => {
|
||||
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: [{ type: 'text_delta', delta: 'x' }] });
|
||||
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');
|
||||
@@ -319,9 +356,9 @@ describe('exportProjectTranscript', () => {
|
||||
role: 'assistant',
|
||||
content: 'final coalesced text',
|
||||
events: [
|
||||
{ type: 'text_delta', delta: 'final ' },
|
||||
{ type: 'text_delta', delta: 'coalesced text' },
|
||||
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
|
||||
{ kind: 'text', text: 'final ' },
|
||||
{ kind: 'text', text: 'coalesced text' },
|
||||
{ kind: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -341,6 +378,9 @@ describe('exportProjectTranscript', () => {
|
||||
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
|
||||
@@ -354,4 +394,264 @@ describe('exportProjectTranscript', () => {
|
||||
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(lines[0].kind).toBe('header');
|
||||
expect(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);
|
||||
expect(warn.mock.calls[0][0]).toContain('mmal');
|
||||
expect(warn.mock.calls[0][0]).toContain(PROJECT_ID);
|
||||
expect(warn.mock.calls[0][0]).toContain('malformed');
|
||||
|
||||
const lines = readLines(result.path);
|
||||
expect(lines[2].id).toBe('mmal');
|
||||
expect(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);
|
||||
expect(warn.mock.calls[0][0]).toContain('mobj');
|
||||
expect(warn.mock.calls[0][0]).toContain('not_array');
|
||||
|
||||
const lines = readLines(result.path);
|
||||
expect(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(lines[0].attachmentCount).toBe(3);
|
||||
expect(lines[0].commentAttachmentCount).toBe(1);
|
||||
expect(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');
|
||||
|
||||
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(lines[0].kind).toBe('header');
|
||||
expect(lines[2].id).toBe('m1');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user