feat(story-mode): draw a Claude session's turns as comic pages, live

Story Mode is an opt-in header button (showStoryButton, per device, default
off, kept off phones) that opens the active Claude Code session as comic pages:
one page per prompt, panels for reading code, planning, helpers, edits, tests
(POW/KRAK), questions and saving, each with a plain story caption and a
Director's cut caption naming the tools. Files open in the existing file
preview, the code page opens the exact diff, a test board opens the output,
and a running turn redraws as Claude works.

Server: GET /api/sessions/:id/story (experimental) reads a bounded tail of the
session's own transcript. A pure core (src/story-mode.ts) reduces lines,
joins tool calls with their results, splits turns at prompts and
turn_duration, and groups events into at most 12 panels; Bash calls are
sorted by what the command does. The reader (src/story-reader.ts) caches
reduced entries and reads only appended bytes while the file grows.

Frontend: story-mode.js (canvas frame engine, live refresh from transcript and
hook SSE events plus a poll while a turn runs, details drawer, the wire) and
story-mode.css, with two self-hosted OFL comic fonts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:22:06 +02:00
1 parent 0ab71d61d1
commit 2653db10b5
27 files changed
+5746 -6

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
/**
* @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, mkdtempSync, rmSync, 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 { bash, demoTurn, end, prompt, read, resetClock, said } from '../story-fixtures.js';
const URL = '/api/sessions/test-session-1/story';
const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
let dir: string;
let transcript: string;
async function setup(opts: { hookPath?: string | null; mode?: string; remote?: unknown; docker?: unknown } = {}) {
const h = await createRouteTestHarness((app, ctx) => registerStoryRoutes(app, ctx));
const session = h.ctx._session as unknown as Record<string, unknown>;
if (opts.mode) session.mode = opts.mode;
if (opts.remote) session.remote = opts.remote;
if (opts.docker) session.docker = opts.docker;
h.ctx.getTranscriptPath.mockReturnValue(opts.hookPath === undefined ? transcript : opts.hookPath);
return h;
}
beforeEach(() => {
resetClock();
clearStoryReaderCache();
dir = mkdtempSync(join(tmpdir(), 'story-route-'));
transcript = join(dir, 'conversation.jsonl');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
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()]));
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.supported).toBe(true);
expect(body.data.turns.map((t: { prompt: string }) => t.prompt)).toEqual([
'first job',
'The checkout button does nothing on phones. Can you fix it?',
]);
expect(body.data.page.panels.map((p: { kind: string }) => p.kind)).toEqual([
'mission',
'investigate',
'plan',
'backup',
'loop',
'save',
'complete',
]);
// Nothing a Read or an Edit result carried in full ever leaves the server.
expect(res.body).not.toContain('FILE CONTENT');
expect(res.body).not.toContain('WHOLE ORIGINAL FILE');
});
it('draws an older turn by id and answers page null for an unknown one', async () => {
writeFileSync(transcript, jsonl([prompt('first job'), said('Done.'), end(2000), ...demoTurn()]));
const { app } = await setup();
const list = (await app.inject({ method: 'GET', url: URL })).json().data.turns;
const older = await app.inject({ method: 'GET', url: `${URL}?turn=${encodeURIComponent(list[0].id)}` });
expect(older.json().data.page).toMatchObject({ prompt: 'first job', complete: true });
const gone = await app.inject({ method: 'GET', url: `${URL}?turn=nope` });
expect(gone.json().data.page).toBeNull();
});
it('is supported with no turns when there is no transcript yet', async () => {
const { app } = await setup({ hookPath: null });
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json()).toEqual({ success: true, data: { supported: true, turns: [], page: null } });
});
it('answers unsupported for a CLI that writes no Claude transcript', async () => {
const { app } = await setup({ mode: 'codex' });
const data = (await app.inject({ method: 'GET', url: URL })).json().data;
expect(data.supported).toBe(false);
expect(data.reason).toMatch(/different CLI/);
});
it('answers unsupported for remote and Docker sessions, whose transcript is not on this host', async () => {
for (const where of [{ remote: { hostId: 'h1' } }, { docker: { containerName: 'c1' } }]) {
clearStoryReaderCache();
const { app } = await setup(where);
const data = (await app.inject({ method: 'GET', url: URL })).json().data;
expect(data).toMatchObject({ supported: false });
}
});
it('never reads a hook path that is not a transcript', async () => {
writeFileSync(join(dir, 'secret.txt'), jsonl(demoTurn()));
const { app } = await setup({ hookPath: join(dir, 'secret.txt') });
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toEqual({ supported: true, turns: [], page: null });
});
it('fails with OPERATION_FAILED when the transcript cannot be read', 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' });
});
it('404s an unknown session', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/story' });
expect(res.statusCode).toBe(404);
});
it('a live turn grows as the transcript grows', async () => {
writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')]));
const { app } = await setup();
const before = (await app.inject({ method: 'GET', url: URL })).json().data.page;
expect(before.panels.map((p: { kind: string }) => p.kind)).toEqual(['mission', 'investigate']);
expect(before.panels[1].live).toBe(true);
appendFileSync(transcript, jsonl([...bash('b1', 'npm test', 'Run tests', ' Tests 4 passed (4)'), end(9000)]));
const after = (await app.inject({ method: 'GET', url: URL })).json().data.page;
expect(after.panels.map((p: { kind: string }) => p.kind)).toEqual(['mission', 'investigate', 'check', 'complete']);
expect(after.complete).toBe(true);
});
});
describe('readStoryEntries', () => {
it('leaves a line that is still being written for the next read', async () => {
const full = JSON.stringify(prompt('half written'));
writeFileSync(transcript, jsonl([prompt('one')]) + full.slice(0, 20));
const first = await readStoryEntries(transcript);
expect(first.entries.map((e) => e.k)).toEqual(['prompt']);
appendFileSync(transcript, full.slice(20) + '\n');
const second = await readStoryEntries(transcript);
expect(second.entries.filter((e) => e.k === 'prompt')).toHaveLength(2);
});
it('starts over when the file was replaced by a shorter one', async () => {
writeFileSync(transcript, jsonl([prompt('one'), prompt('two'), prompt('three')]));
await readStoryEntries(transcript);
writeFileSync(transcript, jsonl([prompt('new')]));
const again = await readStoryEntries(transcript);
expect(again.entries.map((e) => (e.k === 'prompt' ? e.text : e.k))).toEqual(['new']);
});
it('shares one read between concurrent callers', async () => {
writeFileSync(transcript, jsonl(demoTurn()));
const [a, b] = await Promise.all([readStoryEntries(transcript), readStoryEntries(transcript)]);
expect(a.entries).toBe(b.entries);
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* @fileoverview Builders for Story Mode test transcripts. Every line is INVENTED content shaped like a
* real Claude Code transcript entry (field names as measured on local transcripts, 2026-10-18), so no
* real prompt, path or output ever lands in the repo.
*/
let clock = 0;
const at = () => new Date(Date.UTC(2026, 9, 18, 9, 41, 0) + (clock += 1000)).toISOString();
export function resetClock(): void {
clock = 0;
}
export const prompt = (text: string, extra: Record<string, unknown> = {}) => ({
type: 'user',
uuid: `u-${clock + 1}`,
timestamp: at(),
isSidechain: false,
message: { role: 'user', content: text },
turnOrigin: 'human',
promptSource: 'typed',
...extra,
});
export const said = (text: string) => ({
type: 'assistant',
timestamp: at(),
message: { role: 'assistant', content: [{ type: 'text', text }] },
});
export const tool = (id: string, name: string, input: Record<string, unknown>) => ({
type: 'assistant',
timestamp: at(),
message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] },
});
export const result = (
id: string,
content: string,
opts: { isError?: boolean; toolUseResult?: Record<string, unknown> } = {}
) => ({
type: 'user',
timestamp: at(),
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: id, content, is_error: opts.isError ?? false }],
},
toolUseResult: opts.toolUseResult,
});
export const end = (durationMs: number) => ({
type: 'system',
subtype: 'turn_duration',
durationMs,
isMeta: true,
timestamp: at(),
});
/** An Edit call and its result, with a structuredPatch like Claude Code writes. */
export function edit(id: string, file: string, lines: string[], oldStart = 10) {
const added = lines.filter((l) => l.startsWith('+')).length;
const removed = lines.filter((l) => l.startsWith('-')).length;
const context = lines.length - added - removed;
return [
tool(id, 'Edit', { file_path: file, old_string: 'old', new_string: 'new' }),
result(id, 'The file has been updated successfully.', {
toolUseResult: {
filePath: file,
oldString: 'old',
newString: 'new',
originalFile: 'WHOLE ORIGINAL FILE THAT MUST NEVER REACH THE PAGE',
structuredPatch: [
{ oldStart, oldLines: removed + context, newStart: oldStart, newLines: added + context, lines },
],
userModified: false,
replaceAll: false,
},
}),
];
}
export const bash = (id: string, command: string, description: string, output: string, isError = false) => [
tool(id, 'Bash', { command, description }),
result(id, output, { isError, toolUseResult: { stdout: output, stderr: '', interrupted: false } }),
];
export const read = (id: string, file: string) => [
tool(id, 'Read', { file_path: file }),
result(id, 'FILE CONTENT THAT MUST NEVER REACH THE PAGE', {
toolUseResult: {
type: 'text',
file: { filePath: file, content: 'FILE CONTENT', numLines: 3, startLine: 1, totalLines: 3 },
},
}),
];
/** A complete demo turn: read, plan, helper, fix, failing test, fix, passing test, commit. */
export function demoTurn(): unknown[] {
return [
prompt('The checkout button does nothing on phones. Can you fix it?'),
said('Let me see how checkout works. I will start with the button.'),
...read('r1', '/work/demo-shop/src/checkout/CheckoutButton.tsx'),
...read('r2', '/work/demo-shop/src/styles/banner.css'),
...bash(
'b1',
'grep -rn "onClick" src/checkout',
'Find click handlers',
'src/checkout/CheckoutButton.tsx:7: onClick'
),
tool('t1', 'TodoWrite', {
todos: [
{ content: 'Fix the banner', status: 'in_progress' },
{ content: 'Test at phone size', status: 'pending' },
],
}),
result('t1', 'Todos have been modified successfully.'),
tool('a1', 'Agent', {
description: 'Write a phone-size test',
subagent_type: 'general-purpose',
prompt: 'Write a test.',
}),
result('a1', 'Test written.', {
toolUseResult: {
status: 'completed',
agentId: 'ag1',
content: [{ type: 'text', text: 'Test written. It fails today.' }],
totalToolUseCount: 3,
totalDurationMs: 30000,
},
}),
...edit('e1', '/work/demo-shop/src/styles/banner.css', [' z-index: 50;', '+ pointer-events: none;', ' }']),
...bash('b2', 'npm test', 'Run the test suite', ' Tests 1 failed | 41 passed (42)\nExit code 1', true),
...edit('e2', '/work/demo-shop/src/styles/banner.css', [
'-.promo-banner__inner { pointer-events: auto; }',
'+.promo-banner__cta { pointer-events: auto; }',
]),
...bash('b3', 'npm test', 'Run the test suite', ' Tests 42 passed (42)'),
...bash('b4', 'git commit -m "fix: banner"', 'Commit the fix', '[main 4be19d2] fix: banner'),
said('Fixed. The banner sat on top of the button. The same fix covers the cart page.'),
end(432_000),
];
}
+329
View File
@@ -0,0 +1,329 @@
/**
* @fileoverview Story Mode core (src/story-mode.ts): transcript lines → entries → turns → comic page.
* Pure functions on invented transcripts (test/story-fixtures.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import {
STORY_LIMITS,
buildStoryPage,
buildStoryTurns,
classifyBashCommand,
createStoryReducer,
exactDuration,
firstSentence,
parseTestCounts,
roughDuration,
summarizeTurns,
type StoryEntry,
} from '../src/story-mode.js';
import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from './story-fixtures.js';
function entriesOf(lines: unknown[]): StoryEntry[] {
const reducer = createStoryReducer();
return lines.flatMap((l) => reducer.reduce(l));
}
const pageOf = (lines: unknown[]) => {
const turns = buildStoryTurns(entriesOf(lines));
return buildStoryPage(turns[turns.length - 1]);
};
beforeEach(() => resetClock());
describe('classifyBashCommand', () => {
it.each([
['npm test', 'test'],
['npm test > out.log 2>&1', 'test'],
['cd app && npx vitest run src/a.test.ts', 'test'],
['python3 -m pytest -q', 'test'],
['npm run typecheck', 'build'],
['npx eslint src', 'build'],
['git commit -m "fix"', 'save'],
['git push origin HEAD', 'save'],
['gh pr create --title x', 'save'],
["sed -i 's/a/b/' file.ts", 'edit'],
['echo hi > notes.txt', 'edit'],
['cat > config.json <<EOF', 'edit'],
['ls -la', 'look'],
['cd src && grep -rn foo .', 'look'],
['git status --short', 'look'],
['ls 2>/dev/null', 'look'],
['curl -s localhost:3000/api/status', 'run'],
['node scripts/build.mjs', 'run'],
])('%s → %s', (command, intent) => {
expect(classifyBashCommand(command)).toBe(intent);
});
});
describe('parseTestCounts', () => {
it.each([
[' Tests 1 failed | 41 passed (42)', { passed: 41, failed: 1 }],
['Tests: 2 failed, 40 passed, 42 total', { passed: 40, failed: 2 }],
['==== 1 failed, 41 passed in 6.31s ====', { passed: 41, failed: 1 }],
[' 41 passing (2s)\n 1 failing\n', { passed: 41, failed: 1 }],
['test result: ok. 42 passed; 0 failed; 0 ignored', { passed: 42, failed: 0 }],
])('reads %j', (output, expected) => {
expect(parseTestCounts(output)).toEqual(expected);
});
it('returns null without a summary line', () => {
expect(parseTestCounts('compiled successfully')).toBeNull();
});
});
describe('text helpers', () => {
it('firstSentence strips markdown and stops at the first sentence', () => {
expect(firstSentence('**Found it.** The `banner` covers [the button](http://x).')).toBe('Found it.');
expect(firstSentence('No full stop here')).toBe('No full stop here');
});
it('formats durations for both captions', () => {
expect(roughDuration(432_000)).toBe('7 minutes');
expect(exactDuration(432_000)).toBe('7 min 12 s');
expect(roughDuration(4_000)).toBe('4 seconds');
expect(roughDuration(3_900_000)).toBe('1 h 5 min');
});
});
describe('createStoryReducer', () => {
it('never keeps file contents, originals or images', () => {
const entries = entriesOf([prompt('go'), ...read('r1', '/w/a.ts'), ...edit('e1', '/w/a.ts', ['+x'])]);
const text = JSON.stringify(entries);
expect(text).not.toContain('FILE CONTENT');
expect(text).not.toContain('WHOLE ORIGINAL FILE');
});
it('keeps an edit as structuredPatch hunks with exact counts', () => {
const entries = entriesOf([prompt('go'), ...edit('e1', '/w/a.ts', [' a', '-b', '+c', '+d'], 7)]);
const r = entries.find((e) => e.k === 'result');
expect(r).toMatchObject({
k: 'result',
edit: {
file: '/w/a.ts',
added: 2,
removed: 1,
created: false,
hunks: [{ oldStart: 7, lines: [' a', '-b', '+c', '+d'] }],
},
});
});
it('turns a new file written with Write into added lines', () => {
const entries = entriesOf([
prompt('go'),
tool('w1', 'Write', { file_path: '/w/new.ts', content: 'a\nb\n' }),
result('w1', 'File created', {
toolUseResult: { type: 'create', filePath: '/w/new.ts', content: 'a\nb\n', structuredPatch: [] },
}),
]);
expect(entries.find((e) => e.k === 'result')).toMatchObject({
edit: { created: true, added: 2, removed: 0, hunks: [{ lines: ['+a', '+b'] }] },
});
});
it('keeps only the tail of a long output', () => {
const long = Array.from({ length: 500 }, (_, i) => `line ${i}`).join('\n');
const entries = entriesOf([prompt('go'), ...bash('b1', 'node x.js', 'Run it', long)]);
const r = entries.find((e) => e.k === 'result') as Extract<StoryEntry, { k: 'result' }>;
expect(r.outputTruncated).toBe(true);
expect(r.output?.split('\n')).toHaveLength(STORY_LIMITS.outputLines);
expect(r.output?.endsWith('line 499')).toBe(true);
});
it('skips meta, compact summaries, companions, sidechains and local command wrappers', () => {
const entries = entriesOf([
prompt('meta', { isMeta: true }),
prompt('summary', { isCompactSummary: true }),
prompt('companion', { turnCompanion: true }),
prompt('side', { isSidechain: true }),
prompt('<local-command-stdout>ok</local-command-stdout>'),
prompt('<bash-input>ls</bash-input>'),
]);
expect(entries).toEqual([]);
});
it('reads slash commands, task notifications, interrupts and strips system reminders', () => {
const entries = entriesOf([
prompt('<command-name>/review</command-name><command-args>42</command-args>'),
prompt('<task-notification><summary>Build finished</summary></task-notification>', {
turnOrigin: 'task_notification',
}),
prompt('[Request interrupted by user]'),
prompt('Fix it<system-reminder>secret plumbing</system-reminder>'),
]);
expect(entries.map((e) => (e.k === 'prompt' ? [e.k, e.text, e.origin] : [e.k]))).toEqual([
['prompt', '/review 42', 'human'],
['prompt', 'Build finished', 'task'],
['interrupt'],
['prompt', 'Fix it', 'human'],
]);
});
it('ignores lines that are not transcript objects', () => {
const reducer = createStoryReducer();
expect(reducer.reduce(null)).toEqual([]);
expect(reducer.reduce('text')).toEqual([]);
expect(reducer.reduce({ type: 'ai-title', title: 'x' })).toEqual([]);
});
});
describe('buildStoryTurns', () => {
it('splits turns at prompts and ends them at turn_duration', () => {
const turns = buildStoryTurns(
entriesOf([prompt('one'), said('ok'), end(1000), prompt('two'), ...bash('b', 'ls', 'List', 'a')])
);
expect(turns.map((t) => [t.prompt, t.ended])).toEqual([
['one', true],
['two', false],
]);
expect(turns[0].durationMs).toBe(1000);
});
it('a prompt queued while Claude works joins the running turn', () => {
const turns = buildStoryTurns(
entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), prompt('also this', { promptSource: 'queued' })])
);
expect(turns).toHaveLength(1);
expect(turns[0].events.filter((e) => e.intent === 'prompt')).toHaveLength(2);
});
it('marks a turn whose prompt is outside the window as partial', () => {
const turns = buildStoryTurns(entriesOf([...bash('b', 'ls', 'List', 'a'), end(5000)]));
expect(turns[0]).toMatchObject({ partial: true, prompt: '', ended: true });
});
it('flags failed calls and pending calls', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('go'),
...bash('b1', 'npm test', 'Test', 'Exit code 1', true),
tool('b2', 'Bash', { command: 'sleep 9' }),
])
);
const [failed, running] = turns[0].events.filter((e) => e.tool === 'Bash');
expect(failed).toMatchObject({ ok: false, intent: 'test' });
expect(running).toMatchObject({ pending: true });
expect(running.ok).toBeUndefined();
});
it('an interrupt ends the turn', () => {
const turns = buildStoryTurns(
entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])
);
expect(turns[0]).toMatchObject({ ended: true, interrupted: true });
expect(summarizeTurns(turns)[0]).toMatchObject({ complete: false, interrupted: true });
});
});
describe('buildStoryPage', () => {
it('draws the demo turn as mission, investigate, plan, backup, loop, save, complete', () => {
const page = pageOf(demoTurn());
expect(page.panels.map((p) => p.kind)).toEqual([
'mission',
'investigate',
'plan',
'backup',
'loop',
'save',
'complete',
]);
expect(page).toMatchObject({ complete: true, toolCalls: 10, filesChanged: 1, added: 2, removed: 1, helpers: 1 });
});
it('writes plain story captions and tool-level director captions', () => {
const page = pageOf(demoTurn());
const byKind = Object.fromEntries(page.panels.map((p) => [p.kind, p]));
expect(byKind.mission.caption.story).toBe('You give Claude a job.');
expect(byKind.investigate.caption.story).toBe('Claude reads 2 files to find its way around.');
expect(byKind.investigate.caption.director).toBe('Read ×2 · Bash');
expect(byKind.plan.caption.story).toBe('Claude writes a plan: 2 steps.');
expect(byKind.backup.caption.story).toBe('Claude calls in 1 helper.');
expect(byKind.backup.caption.director).toContain('3 calls inside');
expect(byKind.loop).toMatchObject({ rounds: 2, ok: true, added: 2, removed: 1 });
expect(byKind.loop.caption.story).toBe('Fix and test, 2 rounds. It passes in the end.');
expect(byKind.save.caption.story).toBe('Claude saves the work.');
expect(byKind.complete.caption.story).toBe('Done in 7 minutes.');
expect(byKind.complete.caption.director).toContain('7 min 12 s');
});
it('gives balloons to the prompt and to what Claude says before a panel and at the end', () => {
const page = pageOf(demoTurn());
expect(page.panels[0].balloon).toEqual({
who: 'you',
text: 'The checkout button does nothing on phones. Can you fix it?',
});
expect(page.panels[1].balloon).toEqual({ who: 'claude', text: 'Let me see how checkout works.' });
expect(page.panels[page.panels.length - 1].balloon).toEqual({ who: 'claude', text: 'Fixed.' });
});
it('lists files and keeps edits reachable from the panel', () => {
const page = pageOf(demoTurn());
const investigate = page.panels.find((p) => p.kind === 'investigate');
expect(investigate?.files).toEqual([
'/work/demo-shop/src/checkout/CheckoutButton.tsx',
'/work/demo-shop/src/styles/banner.css',
]);
const loop = page.panels.find((p) => p.kind === 'loop');
const editEvents = loop?.events.map((i) => page.events[i]).filter((e) => e.edit);
expect(editEvents?.map((e) => e.edit?.hunks[0].lines)).toEqual([
[' z-index: 50;', '+ pointer-events: none;', ' }'],
['-.promo-banner__inner { pointer-events: auto; }', '+.promo-banner__cta { pointer-events: auto; }'],
]);
});
it('a failing check says so and reads the test counts', () => {
const page = pageOf([
prompt('go'),
...bash('b1', 'npm test', 'Run tests', ' Tests 3 failed | 39 passed (42)', true),
]);
const check = page.panels.find((p) => p.kind === 'check');
expect(check).toMatchObject({ ok: false, tests: { passed: 39, failed: 3 }, live: true });
expect(check?.caption.story).toBe('3 tests fail.');
});
it('an unfinished turn has no complete panel and marks its newest panel live', () => {
const page = pageOf([prompt('go'), ...read('r1', '/w/a.ts'), said('Now the fix.')]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate']);
expect(page.panels[1]).toMatchObject({ live: true });
expect(page.complete).toBe(false);
});
it('folds a long turn into montage panels so the page fits', () => {
const lines: unknown[] = [prompt('big job')];
for (let i = 0; i < 30; i++) {
lines.push(...read(`r${i}`, `/w/f${i}.ts`));
lines.push(...bash(`b${i}`, `node step${i}.js`, `Step ${i}`, 'ok'));
}
lines.push(end(60_000));
const page = pageOf(lines);
expect(page.panels.length).toBeLessThanOrEqual(STORY_LIMITS.panelsPerPage);
expect(page.panels[0].kind).toBe('mission');
expect(page.panels[page.panels.length - 1].kind).toBe('complete');
expect(page.panels.some((p) => p.kind === 'montage')).toBe(true);
// Every tool call is still on the page, in some panel.
expect(new Set(page.panels.flatMap((p) => p.events)).size).toBe(61);
});
it('drops the oldest outputs first when a page is over its text budget', () => {
const big = 'x'.repeat(7000);
const lines: unknown[] = [prompt('go')];
for (let i = 0; i < 80; i++) lines.push(...bash(`b${i}`, `node s${i}.js`, `Step ${i}`, big));
const page = pageOf(lines);
expect(page.truncated).toBe(true);
const bashEvents = page.events.filter((e) => e.tool === 'Bash');
expect(bashEvents[0].output).toBeUndefined();
expect(bashEvents[0].outputTruncated).toBe(true);
expect(bashEvents[bashEvents.length - 1].output).toBe(big);
});
it('an origin other than you changes the mission caption', () => {
const page = pageOf([
prompt('<task-notification><summary>Build done</summary></task-notification>', {
turnOrigin: 'task_notification',
}),
said('Nice.'),
end(1000),
]);
expect(page.panels[0].caption.story).toBe('A background job reports back.');
expect(page.origin).toBe('task');
});
});