From 250a77d945bcc0a540479b4a6ebf62d78674b169 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 14:34:28 +0200 Subject: [PATCH] fix(story-mode): the reader cannot stall the server, and reads only real transcripts From review round 7 (security): - Eviction past the per-transcript caps dropped entries one shift() at a time after the whole tail was parsed: quadratic and never yielding (a 2 s event-loop stall on a 70 MB long turn, 51 s from 300k entries). It is one splice, done at every 1 MB yield, so the peak stays near the caps; and a window that saw a turn start which was then trimmed away no longer widens to a bigger tail that would be trimmed the same. - A FIFO or a device in place of a transcript or a helper's .meta.json (stat said size 0, then open() waited for a writer) pinned a libuv worker per request; four of them hang every fs, dns and crypto call on the server. Files are now opened non-blocking, checked with the handle's own stat (a regular file only) and read through that handle. - The cache held 6 transcripts for everyone, so a seventh open view made every poll a full re-read, and a 304 was decided only after reading. An unchanged file is now a 304 from a stat alone, and the cache holds up to 24 transcripts within 30 million characters. - Story Mode is the first route to send a transcript's content to the browser, so the hook-reported path is used only when it really lies (symlinks resolved) in a Claude projects folder: the session's relocated CLAUDE_CONFIG_DIR (now also used for the lookup by id) or the server's. Anything else falls back to the lookup by conversation id. Tests move their transcripts into the (temp) projects folder and cover a path outside it, a symlink out of it, a FIFO transcript, a FIFO meta file and a 100,000-entry tail. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/session.ts | 5 ++ src/story-reader.ts | 136 ++++++++++++++++++++++++++----- src/web/routes/story-routes.ts | 78 ++++++++++++++---- test/routes/story-routes.test.ts | 71 ++++++++++++++-- test/story-mode.browser.test.ts | 10 ++- 6 files changed, 258 insertions(+), 44 deletions(-) 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 () => {