diff --git a/docs/api-reference.md b/docs/api-reference.md index fad894f9..cc77c374 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1059,7 +1059,7 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons - `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means the transcript is longer than the part that was read, so older turns are not listed. - `GET /api/sessions/:id/story/helpers/:agentId`: one helper (an Agent call, `agentId` from its event's `helper.agentId`) drawn from its own transcript, `/subagents/agent-.jsonl` beside the session's. `data` is `{ supported: true, helper: { agentId, type?, description?, finished? }, page }`, the same page shape, or `page: null` while the helper has written nothing yet. `agentId` must match `[A-Za-z0-9_-]{1,64}` (`400` otherwise). -Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported, else `.jsonl` under the Claude projects directory. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. A transcript that cannot be read is a `500` with `OPERATION_FAILED`. +Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported when it really lies (symlinks resolved) in a Claude projects folder (the session's relocated `CLAUDE_CONFIG_DIR`, or the server's), else `.jsonl` under that folder. Only a regular file is read, through one non-blocking handle, so a FIFO or a device in its place fails at once. An unchanged file is a `304` decided from a stat, before anything is read. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. A transcript that cannot be read is a `500` with `OPERATION_FAILED`. Responses carry a weak `ETag` with `Cache-Control: no-cache`; a matching `If-None-Match` gets a `304` without the page being rebuilt, which keeps a live view's frequent refetches cheap. diff --git a/src/session.ts b/src/session.ts index c9d494cb..a54e1431 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2504,6 +2504,11 @@ export class Session extends EventEmitter { } /** The session's Claude config dir when it has been relocated (#255), else undefined. */ + /** The Claude config dir this session runs with (`CLAUDE_CONFIG_DIR` in its env overrides), if relocated. */ + get claudeConfigDir(): string | undefined { + return this._claudeConfigDir(); + } + private _claudeConfigDir(): string | undefined { // Trimmed like `claudeProjectsDir()` trims the process-wide override: the // envOverrides schema validates keys only, and a whitespace-only value would diff --git a/src/story-reader.ts b/src/story-reader.ts index ca21b67b..d86ab99d 100644 --- a/src/story-reader.ts +++ b/src/story-reader.ts @@ -16,7 +16,8 @@ * which file is read (story-routes.ts). */ -import { open, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { open, stat, type FileHandle } from 'node:fs/promises'; import { LRUMap } from './utils/lru-map.js'; import { createStoryReducer, type StoryEntry, type StoryReducer } from './story-mode.js'; @@ -32,8 +33,9 @@ const TAIL_STEPS = [STORY_TAIL_BYTES, 24 * 1024 * 1024, 64 * 1024 * 1024]; const MAX_ENTRIES = 20_000; /** Approximate characters kept per transcript across all entries. */ const MAX_CHARS = 6_000_000; -/** Transcripts cached at once. */ -const MAX_TRANSCRIPTS = 6; +/** Transcripts cached at once, and their characters together (the oldest go first past either). */ +const MAX_TRANSCRIPTS = 24; +const MAX_CACHE_CHARS = 30_000_000; interface CacheEntry { sidechain: boolean; @@ -46,6 +48,8 @@ interface CacheEntry { reducer: StoryReducer; /** The read began mid-file, so the oldest turn may be cut. */ partial: boolean; + /** A turn started inside what was read (it may have been trimmed away since). */ + sawTurnStart: boolean; } export interface StoryEntriesResult { @@ -62,26 +66,81 @@ const hasTurnStart = (entries: StoryEntry[]) => entries.some((e) => e.k === 'pro const cache = new LRUMap({ maxSize: MAX_TRANSCRIPTS }); const inFlight = new Map>(); +/** + * The version a read of `path` would report (size and mtime), from a stat only: lets the route answer + * 304 for an unchanged page without reading or parsing anything, cached or not. Null for anything + * but a regular file. + */ +export async function storyFileVersion(path: string): Promise { + try { + const info = await stat(path); + return info.isFile() ? versionOf(info.size, info.mtimeMs) : null; + } catch { + return null; + } +} + +/** Evicts the least recently used transcripts while the cache holds more characters than its budget. */ +function keepCacheInBudget(keep: string): void { + let total = 0; + for (const e of cache.values()) total += e.chars; + for (const key of cache.keysInOrder()) { + if (total <= MAX_CACHE_CHARS) break; + if (key === keep) continue; + total -= cache.get(key)?.chars ?? 0; + cache.delete(key); + } +} + /** For tests: forget every cached transcript. */ export function clearStoryReaderCache(): void { cache.clear(); inFlight.clear(); } -async function readRange(path: string, start: number, end: number): Promise { +/** + * Opens `path` for reading only if it is a regular file, and keeps the handle: every later check and + * read goes through it, never through the path again. Opened non-blocking, so a FIFO (or a symlink + * to one) swapped in never pins a libuv worker waiting for a writer; a device or a FIFO is refused by + * the handle's own stat. + */ +export async function openRegularFile(path: string): Promise<{ handle: FileHandle; size: number; mtimeMs: number }> { + const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const info = await handle.stat(); + if (!info.isFile()) throw new Error('Transcript is not a regular file'); + return { handle, size: info.size, mtimeMs: info.mtimeMs }; + } catch (err) { + await handle.close(); + throw err; + } +} + +async function readRange(handle: FileHandle, start: number, end: number): Promise { const length = Math.max(0, end - start); const buf = Buffer.alloc(length); - const handle = await open(path, 'r'); + let read = 0; + while (read < length) { + const { bytesRead } = await handle.read(buf, read, length - read, start + read); + if (bytesRead === 0) break; + read += bytesRead; + } + return read === length ? buf : buf.subarray(0, read); +} + +/** A small regular file read whole (a helper's `.meta.json`), or null when it is anything else or too big. */ +export async function readSmallRegularFile(path: string, maxBytes: number): Promise { + let opened: Awaited>; try { - let read = 0; - while (read < length) { - const { bytesRead } = await handle.read(buf, read, length - read, start + read); - if (bytesRead === 0) break; - read += bytesRead; - } - return read === length ? buf : buf.subarray(0, read); + opened = await openRegularFile(path); + } catch { + return null; + } + try { + if (opened.size > maxBytes) return null; + return (await readRange(opened.handle, 0, opened.size)).toString('utf8'); } finally { - await handle.close(); + await opened.handle.close(); } } @@ -125,25 +184,54 @@ async function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): P for (const reduced of entry.reducer.reduce(parsed)) { entry.entries.push(reduced); entry.chars += entryChars(reduced); + if (reduced.k === 'prompt' && !reduced.queued) entry.sawTurnStart = true; } } sinceYield += nl - pos + 1; pos = nl + 1; if (sinceYield >= YIELD_EVERY_BYTES) { sinceYield = 0; + // Trimmed as it grows, so the peak stays near the caps, not the size of the window. + trim(entry); await yieldToLoop(); } } - while (entry.entries.length > MAX_ENTRIES || (entry.chars > MAX_CHARS && entry.entries.length > 1)) { - const dropped = entry.entries.shift(); - if (dropped) entry.chars -= entryChars(dropped); - entry.partial = true; + trim(entry); +} + +/** + * Drops the oldest entries over the caps in ONE splice: dropping them one `shift()` at a time is + * quadratic and never yields (seconds on a long turn's tail). + */ +function trim(entry: CacheEntry): void { + const all = entry.entries; + let drop = 0; + let chars = entry.chars; + while (all.length - drop > MAX_ENTRIES || (chars > MAX_CHARS && all.length - drop > 1)) { + chars -= entryChars(all[drop]); + drop++; } + if (!drop) return; + all.splice(0, drop); + entry.chars = chars; + entry.partial = true; } async function load(path: string, sidechain: boolean): Promise { - const info = await stat(path); - if (!info.isFile()) throw new Error('Transcript is not a regular file'); + const { handle, ...info } = await openRegularFile(path); + try { + return await loadFrom(handle, info, path, sidechain); + } finally { + await handle.close(); + } +} + +async function loadFrom( + handle: FileHandle, + info: { size: number; mtimeMs: number }, + path: string, + sidechain: boolean +): Promise { const key = sidechain ? `${path}#sidechain` : path; const cached = cache.get(key); const version = versionOf(info.size, info.mtimeMs); @@ -157,11 +245,12 @@ async function load(path: string, sidechain: boolean): Promise= cached.size ) { // The file only grew: read what was appended since the last complete line. - const buf = await readRange(path, cached.offset, info.size); + const buf = await readRange(handle, cached.offset, info.size); await ingest(cached, buf, false); cached.size = info.size; cached.mtimeMs = info.mtimeMs; cache.set(key, cached); + keepCacheInBudget(key); return { entries: cached.entries, partial: cached.partial, version }; } // First read, a rewrite, or more appended than a tail holds: start over from the tail, and take a @@ -179,12 +268,15 @@ async function load(path: string, sidechain: boolean): Promise 0, + sawTurnStart: false, }; - await ingest(fresh, await readRange(path, start, info.size), start > 0); - if (start === 0 || hasTurnStart(fresh.entries)) break; + await ingest(fresh, await readRange(handle, start, info.size), start > 0); + // A turn start seen and then trimmed away would be trimmed from a bigger tail too: stop there. + if (start === 0 || fresh.sawTurnStart || hasTurnStart(fresh.entries)) break; } const done = fresh as CacheEntry; cache.set(key, done); + keepCacheInBudget(key); return { entries: done.entries, partial: done.partial, version }; } diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts index bc77c8d4..45635466 100644 --- a/src/web/routes/story-routes.ts +++ b/src/web/routes/story-routes.ts @@ -20,8 +20,8 @@ */ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { readFile, stat } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; +import { realpath } from 'node:fs/promises'; +import { basename, dirname, join, sep } from 'node:path'; import { ApiErrorCode, createErrorResponse, @@ -31,8 +31,8 @@ import { } from '../../types.js'; import { getCli } from '../../config/cli-registry/registry.js'; import { buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js'; -import { readStoryEntries } from '../../story-reader.js'; -import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js'; +import { readSmallRegularFile, readStoryEntries, storyFileVersion } from '../../story-reader.js'; +import { claudeProjectsDir, findClaudeTranscriptPath } from '../../utils/claude-transcript.js'; import { findSessionOrFail } from '../route-helpers.js'; import type { ConfigPort, SessionPort } from '../ports/index.js'; @@ -44,10 +44,45 @@ const SERVER_EPOCH = Date.now().toString(36); /** A helper's `.meta.json` is a few hundred bytes; anything bigger is not one. */ const MAX_META_BYTES = 64 * 1024; -/** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */ -async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise { - if (hookPath && hookPath.startsWith('/') && hookPath.endsWith('.jsonl')) return hookPath; - return findClaudeTranscriptPath(conversationId); +/** + * Whether `path` really lies in a Claude projects folder (the session's relocated config dir, or the + * server's): symlinks resolved on both sides. Story Mode sends a transcript's CONTENT to the + * browser, so the hook-reported path is trusted only where Claude Code writes transcripts. + */ +async function insideClaudeProjects(path: string, configDir?: string): Promise { + let real: string; + try { + real = await realpath(path); + } catch { + return false; + } + for (const root of new Set([claudeProjectsDir(configDir), claudeProjectsDir()])) { + let base = root; + try { + base = await realpath(root); + } catch { + // a projects folder that does not exist holds nothing + continue; + } + if (real.startsWith(base.endsWith(sep) ? base : base + sep)) return true; + } + return false; +} + +/** The hook-reported path when it is a transcript in a projects folder, else a lookup by conversation id. */ +async function resolveTranscriptPath( + hookPath: string | null, + conversationId: string, + configDir?: string +): Promise { + if ( + hookPath && + hookPath.startsWith('/') && + hookPath.endsWith('.jsonl') && + (await insideClaudeProjects(hookPath, configDir)) + ) + return hookPath; + return findClaudeTranscriptPath(conversationId, configDir); } type Source = { unsupported: string } | { path: string | null }; @@ -64,15 +99,23 @@ async function storySource(ctx: SessionPort & ConfigPort, id: string, req: Fasti 'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.', }; } - return { path: await resolveTranscriptPath(ctx.getTranscriptPath(id), session.claudeSessionId || session.id) }; + return { + path: await resolveTranscriptPath( + ctx.getTranscriptPath(id), + session.claudeSessionId || session.id, + session.claudeConfigDir + ), + }; } /** * Sets the ETag and answers true (after sending a 304) when the browser already holds this version. * A live view refetches every few seconds; while the file has not changed, nothing gets built. */ +const etagOf = (tag: string) => `W/"story-${SERVER_EPOCH}-${tag}"`; + function notModified(req: FastifyRequest, reply: FastifyReply, tag: string): boolean { - const etag = `W/"story-${SERVER_EPOCH}-${tag}"`; + const etag = etagOf(tag); reply.header('ETag', etag).header('Cache-Control', 'no-cache'); if (req.headers['if-none-match'] !== etag) return false; reply.code(304).send(); @@ -81,8 +124,10 @@ function notModified(req: FastifyRequest, reply: FastifyReply, tag: string): boo async function readHelperMeta(path: string): Promise<{ type?: string; description?: string; finished?: boolean }> { try { - if ((await stat(path)).size > MAX_META_BYTES) return {}; - const meta = JSON.parse(await readFile(path, 'utf8')) as Record; + // A regular file only, read through one handle (a FIFO or a device here must not pin a worker). + const text = await readSmallRegularFile(path, MAX_META_BYTES); + if (text === null) return {}; + const meta = JSON.parse(text) as Record; return { type: typeof meta.agentType === 'string' ? meta.agentType.slice(0, 80) : undefined, description: typeof meta.description === 'string' ? meta.description.slice(0, 300) : undefined, @@ -100,6 +145,12 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con const source = await storySource(ctx, id, req); if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } }; if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } }; + const turnKey = typeof turn === 'string' && turn ? turn : ''; + const tagOf = (version: string) => `${version}-${encodeURIComponent(turnKey || 'latest')}`; + // An unchanged file is a 304 from a stat alone: no read, no parse, whatever the cache holds. + const before = await storyFileVersion(source.path); + if (before && req.headers['if-none-match'] === etagOf(tagOf(before)) && notModified(req, reply, tagOf(before))) + return; let read; try { @@ -108,8 +159,7 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con reply.code(500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript'); } - const turnKey = typeof turn === 'string' && turn ? turn : ''; - if (notModified(req, reply, `${read.version}-${encodeURIComponent(turnKey || 'latest')}`)) return; + if (notModified(req, reply, tagOf(read.version))) return; const turns = buildStoryTurns(read.entries); const wanted = turnKey ? turns.find((t) => t.id === turnKey) : turns[turns.length - 1]; diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts index c74cadec..92477276 100644 --- a/test/routes/story-routes.test.ts +++ b/test/routes/story-routes.test.ts @@ -2,13 +2,15 @@ * @fileoverview GET /api/sessions/:id/story (Story Mode) against invented transcripts in a temp dir, * plus the reader's incremental tail read. Port: N/A (app.inject()). */ -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerStoryRoutes } from '../../src/web/routes/story-routes.js'; import { clearStoryReaderCache, readStoryEntries } from '../../src/story-reader.js'; +import { claudeProjectsDir } from '../../src/utils/claude-transcript.js'; import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from '../story-fixtures.js'; const URL = '/api/sessions/test-session-1/story'; @@ -30,13 +32,72 @@ async function setup(opts: { hookPath?: string | null; mode?: string; remote?: u beforeEach(() => { resetClock(); clearStoryReaderCache(); - dir = mkdtempSync(join(tmpdir(), 'story-route-')); + // Where Claude Code writes transcripts (under the test's temp HOME): the only place a hook path is read. + mkdirSync(claudeProjectsDir(), { recursive: true }); + dir = mkdtempSync(join(claudeProjectsDir(), 'story-route-')); transcript = join(dir, 'conversation.jsonl'); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); +describe('GET /api/sessions/:id/story: which file is read', () => { + it('a hook path outside the Claude projects folder is never read, nor a symlink out of it', async () => { + const outside = mkdtempSync(join(tmpdir(), 'story-outside-')); + try { + const file = join(outside, 'conversation.jsonl'); + writeFileSync(file, jsonl([prompt('secret job'), said('Done.'), end(2000)])); + let { app } = await setup({ hookPath: file }); + let body = (await app.inject({ method: 'GET', url: URL })).json(); + expect(body.data).toMatchObject({ supported: true, turns: [], page: null }); + const link = join(dir, 'link.jsonl'); + symlinkSync(file, link); + ({ app } = await setup({ hookPath: link })); + body = (await app.inject({ method: 'GET', url: URL })).json(); + expect(body.data).toMatchObject({ supported: true, turns: [], page: null }); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it("a FIFO in place of a helper's meta file costs nothing: the helper still draws", async () => { + writeFileSync(transcript, jsonl([prompt('go'), end(1000)])); + const sub = join(dir, 'conversation', 'subagents'); + mkdirSync(sub, { recursive: true }); + writeFileSync(join(sub, 'agent-h1.jsonl'), jsonl([prompt('helper task'), said('Working.')])); + execFileSync('mkfifo', [join(sub, 'agent-h1.meta.json')]); + const { app } = await setup(); + const started = Date.now(); + const res = await app.inject({ method: 'GET', url: `${URL}/helpers/h1` }); + expect(res.statusCode).toBe(200); + expect(res.json().data.helper).toEqual({ agentId: 'h1' }); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('a transcript with far more entries than are kept reads in one pass and keeps the newest', async () => { + const lines = [prompt('one long turn')]; + for (let i = 0; i < 100_000; i++) lines.push(said(`step ${i}`)); + writeFileSync(transcript, jsonl(lines)); + const started = Date.now(); + const read = await readStoryEntries(transcript); + // Trimmed with one splice per megabyte, never one shift() per entry (seconds at this size). + expect(Date.now() - started).toBeLessThan(4000); + expect(read.entries).toHaveLength(20_000); + expect(read.partial).toBe(true); + }); + + it('a FIFO in place of the transcript fails at once, never waiting for a writer', async () => { + const fifo = join(dir, 'fifo.jsonl'); + execFileSync('mkfifo', [fifo]); + const { app } = await setup({ hookPath: fifo }); + const started = Date.now(); + const res = await app.inject({ method: 'GET', url: URL }); + expect(res.statusCode).toBe(500); + expect(res.json().errorCode).toBe('OPERATION_FAILED'); + expect(Date.now() - started).toBeLessThan(2000); + }); +}); + describe('GET /api/sessions/:id/story', () => { it('draws the newest turn of the hook-reported transcript', async () => { writeFileSync(transcript, jsonl([prompt('first job'), said('Done.'), end(2000), ...demoTurn()])); @@ -103,11 +164,11 @@ describe('GET /api/sessions/:id/story', () => { expect(res.json().data).toEqual({ supported: true, turns: [], page: null }); }); - it('fails with OPERATION_FAILED when the transcript cannot be read', async () => { + it('a hook path that is gone falls back to the lookup by conversation id: nothing yet, not an error', async () => { const { app } = await setup({ hookPath: join(dir, 'missing.jsonl') }); const res = await app.inject({ method: 'GET', url: URL }); - expect(res.statusCode).toBe(500); - expect(res.json()).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ supported: true, turns: [], page: null }); }); it('404s an unknown session', async () => { diff --git a/test/story-mode.browser.test.ts b/test/story-mode.browser.test.ts index 61bcfabd..045e3506 100644 --- a/test/story-mode.browser.test.ts +++ b/test/story-mode.browser.test.ts @@ -3,12 +3,13 @@ * synthetic transcript (test/story-fixtures.ts) handed to the session the way Claude's hooks do it, * then grown while the overlay is open so the live redraw runs off real SSE events. */ -import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; +import { claudeProjectsDir } from '../src/utils/claude-transcript.js'; import { demoTurn, resetClock } from './story-fixtures.js'; const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; @@ -18,6 +19,7 @@ describe('Story Mode in a real browser', () => { let browser: Browser; let page: Page; let root: string; + let transcriptDir: string; let transcript: string; let sessionId: string; let rest: unknown[]; @@ -39,7 +41,10 @@ describe('Story Mode in a real browser', () => { beforeAll(async () => { resetClock(); root = mkdtempSync(join(homedir(), 'story-ui-')); - transcript = join(root, 'conversation.jsonl'); + // Where Claude Code writes transcripts (under the test's temp HOME): the only place a hook path is read. + mkdirSync(claudeProjectsDir(), { recursive: true }); + transcriptDir = mkdtempSync(join(claudeProjectsDir(), 'story-ui-')); + transcript = join(transcriptDir, 'conversation.jsonl'); const turn = demoTurn(); // The turn up to the first edit's result is "running"; the rest arrives during the test. writeFileSync(transcript, jsonl(turn.slice(0, 14))); @@ -71,6 +76,7 @@ describe('Story Mode in a real browser', () => { if (browser) await browser.close(); if (server) await server.stop(); rmSync(root, { recursive: true, force: true }); + rmSync(transcriptDir, { recursive: true, force: true }); }, 60000); it('is off by default: no header button', async () => {