Files
Codeman/test/story-mode.test.ts
T
Codeman maintainer f46369a5af fix(story-mode): last words in context, the check after a push, a fresh copy (review round 10)
From the person who asked for the work, reading fresh pages:
- "The result was 4 passed, 1 failed" under "Mission complete!" read as
  a failing end. A test count is no failed step, and Claude's second
  sentence is kept when it is the outcome ("All 5 tests now pass, and the
  change is committed.").
- "Two places where I didn't follow your wording:" showed one. A list
  intro that announces how many brings that many lines.
- "…would fix it for up to 10 files. Want me to add that?" never said
  what "it" was. An offer that points back brings the start of its
  paragraph ("Suffix swap: the two files with the same time would trade
  their _1 suffix. …").
- "Decisions you may want to change" is something for you, like an offer.
- A second opinion whose call failed read as given; it now reads "after
  trying to ask … (no answer came)".
- "but a step after that fails" after a push sounded as if the push
  broke: when everything after it only looks (git log, status), it is "a
  check after that fails".
- Tests run in a fresh clone read "The tests pass in a fresh copy made
  from remote.git."

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 16:59:29 +02:00

1670 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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,
buildHelperPage,
buildStoryPage,
buildStoryTurns,
classifyBashCommand,
createStoryReducer,
exactDuration,
firstSentence,
heredocWrites,
keyKindOf,
parseTestCounts,
reportsProblems,
planFromText,
printedFiles,
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'],
// Only a segment that RUNS a test is a test; naming a runner is not.
['which pytest', 'look'],
['python3 -m pytest --version', 'look'],
['pytest --collect-only -q', 'look'],
['pip show pytest', 'run'],
['python3 -c "import pytest"', 'run'],
['echo "run pytest later"', 'look'],
['node --test', 'test'],
// Logging into scratch is not editing the project; a redirect into a project file is.
['./rename.sh --dry-run . | tee "$S/dry.txt"', 'run'],
['ls -A > /tmp/before.txt', 'look'],
['ls -A > "$S/before.txt"', 'look'],
['echo "a > b.txt"', 'look'],
['printf "x" > notes.md', 'edit'],
['printf "dist/\\n" >> .gitignore', 'edit'],
['sed --in-place s/a/b/ f.txt', 'edit'],
// A here-document's body is data: `pytest.main()` in it runs nothing.
["cat > app.py <<'EOF'\nimport pytest\npytest.main()\nEOF", 'edit'],
['git commit -m "ignore pytest caches"', 'save'],
// Shell structure is not a command; read-only probes are looking.
['for t in ruff flake8; do $t --version; done', 'look'],
['command -v ruff; git config --get user.name', 'look'],
['if pytest -q; then echo ok; fi', 'test'],
['node --test timer.test.mjs 2>&1 | tail -20', 'test'],
['timeout 60 npm test', 'test'],
['FOO=1 CI=true pytest -q', 'test'],
['cd app && make test', 'test'],
['python3 -m unittest discover', 'test'],
['make', 'build'],
['shellcheck rename.sh', 'build'],
['node --check app.js && git init -q && git add -A && git commit -qm "init"', 'save'],
['git -c user.name=a -c user.email=b commit -m x', 'save'],
['git -C repo push', 'save'],
['git init && git add .', 'run'],
['mkdir -p samples && touch samples/a.jpg', 'run'],
['grep -n "a|b" file.txt', 'look'],
["printf 'x' > out.txt", 'edit'],
])('%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('parseTestCounts beyond "N passed"', () => {
it.each([
['# tests 7\n# suites 0\n# pass 7\n# fail 0', { passed: 7, failed: 0 }],
['ℹ tests 3\nℹ pass 2\nℹ fail 1', { passed: 2, failed: 1 }],
['FAILED test_a.py::test_x\n===== 3 failed in 0.12s =====', { passed: 0, failed: 3 }],
['==== 1 error in 0.05s ====', { passed: 0, failed: 0, errors: 1 }],
['=== 1 failed, 18 passed, 2 errors in 0.40s ===', { passed: 18, failed: 1, errors: 2 }],
[' Tests 3 failed (3)', { passed: 0, failed: 3 }],
])('%j', (output, counts) => {
expect(parseTestCounts(output)).toEqual(counts);
});
it('reportsProblems reads linters, formatters and type checkers', () => {
expect(reportsProblems('Found 3 errors.\n[*] 2 fixable')).toBe(true);
expect(reportsProblems('1 file would be reformatted, 4 files already formatted')).toBe(true);
expect(reportsProblems('✖ 5 problems (3 errors, 2 warnings)')).toBe(true);
expect(reportsProblems("src/a.ts(3,1): error TS2322: Type 'x' is not assignable")).toBe(true);
expect(reportsProblems('All checks passed!\n5 files already formatted\nFound 0 errors.')).toBe(false);
// The last verdict wins: a formatter's preview, then the fix.
expect(
reportsProblems('1 file would be reformatted\n1 file reformatted\nAll checks passed!\n10 files already formatted')
).toBe(false);
expect(
reportsProblems(
'All checks passed!\nWould reformat: a.py\n1 file would be reformatted, 9 files already formatted'
)
).toBe(true);
});
it('a line that only mentions a failure is not a summary', () => {
expect(parseTestCounts('The request failed twice: 2 failed attempts')).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('firstSentence keeps underscores and stars that are not markdown', () => {
expect(firstSentence('I built wcount.py and test_wcount.py.')).toBe('I built wcount.py and test_wcount.py.');
expect(firstSentence('Renamed rename_by_date.sh to __init__.py style.')).toBe(
'Renamed rename_by_date.sh to __init__.py style.'
);
expect(firstSentence('It is *really* done and _very_ fast.')).toBe('It is really done and very fast.');
expect(firstSentence('## Summary\n- Fixed the 2*3 bug.')).toBe('Fixed the 2*3 bug.');
});
it('firstSentence ends a heading at its line', () => {
expect(firstSentence('# Plan: add `wrap` to mathx.py\n\n**Context:** it has clamp().')).toBe(
'Plan: add wrap to mathx.py.'
);
});
it('firstSentence treats code spans as words and an ellipsis or e.g. as no end', () => {
expect(firstSentence('My `mk -- ...` helper call left a stray file named `--`. Then I removed it.')).toBe(
'My mk -- ... helper call left a stray file named --.'
);
expect(firstSentence('Use a flag, e.g. the dry run. Then go.')).toBe('Use a flag, e.g. the dry run.');
expect(firstSentence('Wait... it works now. Good.')).toBe('Wait... it works now.');
});
it('planFromText reads numbered lists and checkboxes, never plain bullets', () => {
expect(planFromText('Todo list:\n\n1. Check the layout.\n2. Write `rename.sh`.\n3) Test it')).toEqual([
{ text: 'Check the layout.', done: false },
{ text: 'Write rename.sh.', done: false },
{ text: 'Test it', done: false },
]);
expect(planFromText('- [x] **Read** the code\n- [ ] Fix it')).toEqual([
{ text: 'Read the code', done: true },
{ text: 'Fix it', done: false },
]);
expect(planFromText('Done:\n- added a flag\n- fixed a bug\n- wrote docs')).toBeNull();
expect(planFromText('1. one\n2. two')).toBeNull();
});
it('printedFiles reads cat, head and tail arguments, and gives up after a cd', () => {
expect(printedFiles('cat README.md "src/a b.ts" && head -n 20 notes.txt | grep x')).toEqual([
'README.md',
'src/a b.ts',
'notes.txt',
]);
expect(printedFiles('cat src/*.ts; tail -f "$LOG"')).toEqual([]);
expect(printedFiles('cd sub && cat a.txt')).toEqual([]);
});
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');
// Past an hour the exact one keeps its seconds, so the two never disagree on the minute.
expect(roughDuration(4_179_269)).toBe('1 h 10 min');
expect(exactDuration(4_179_269)).toBe('1 h 9 min 39 s');
});
});
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>'),
]);
// None is a prompt; a local command's output is kept apart, as what that command printed.
expect(entries).toEqual([{ k: 'local', at: expect.any(String), text: 'ok' }]);
});
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('judges a Bash call on its whole command, though the label is clipped', () => {
const long = `printf '%s' "${'x'.repeat(450)}" > /dev/null && git commit -qm "feat: add" && git push`;
const [turn] = buildStoryTurns(entriesOf([prompt('ship it'), ...bash('b', long, 'Commit and push', 'ok')]));
const ev = turn.events[1];
expect(ev.label.length).toBeLessThanOrEqual(STORY_LIMITS.labelChars);
expect(ev.intent).toBe('save');
expect(ev.ships).toBe('push');
});
it("never lets a turn outlast its own span (a task turn's duration counts from the first prompt)", () => {
const lines = [prompt('job'), ...bash('b', 'ls', 'List', 'a')];
const turns = buildStoryTurns(entriesOf([...lines, { ...end(0), durationMs: 158_267 }]));
expect(turns[0].durationMs).toBeLessThan(10_000);
});
it('a background helper stays running until its notification joins the answer back', () => {
const launched = [
prompt('Docs please'),
tool('a1', 'Agent', { description: 'Write README', subagent_type: 'general-purpose', prompt: 'Write it' }),
result('a1', 'Async agent launched successfully. (This tool result is internal metadata.) agentId: h9', {
toolUseResult: { isAsync: true, status: 'async_launched', agentId: 'h9', description: 'Write README' },
}),
said('The helper is on it.'),
end(1000),
];
let page = pageOf(launched);
const helper = page.events[1].helper!;
expect(helper).toMatchObject({ background: true, running: true, agentId: 'h9' });
expect(helper.result).toBeUndefined();
expect(page.helpersRunning).toBe(1);
expect(page.panels.find((p) => p.kind === 'backup')!.caption.story).toBe(
'Claude sends 1 helper off to work in the background.'
);
expect(page.panels[page.panels.length - 1].caption.story).toBe(
"Claude's part is done in 1 second. A helper is still working."
);
const notification = [
'<task-notification>',
'<task-id>h9</task-id>',
'<tool-use-id>a1</tool-use-id>',
'<status>completed</status>',
'<summary>Agent "Write README" finished</summary>',
'<result>I wrote README.md.</result>',
'<usage><tool_uses>4</tool_uses><duration_ms>9000</duration_ms></usage>',
'</task-notification>',
].join('\n');
const turns = buildStoryTurns(
entriesOf([...launched, prompt(notification, { turnOrigin: 'task_notification' }), said('Thanks.'), end(500)])
);
expect(turns).toHaveLength(2);
page = buildStoryPage(turns[0]);
expect(page.events[1].helper).toMatchObject({ result: 'I wrote README.md.', toolUses: 4, durationMs: 9000 });
expect(page.events[1].helper!.running).toBeUndefined();
expect(page.helpersRunning).toBe(0);
const second = buildStoryPage(turns[1]);
expect(second.prompt).toBe('Agent "Write README" finished');
expect(second.events[0].output).toBe('I wrote README.md.');
// The report's turn links back to the helper it answers.
expect(second.events[0].helper).toMatchObject({ agentId: 'h9', result: 'I wrote README.md.' });
});
it('a prompt typed while Claude works (a queued_command attachment, no user line) joins the turn', () => {
const queued = (prompt: string, origin: Record<string, unknown>, mode = 'prompt') => ({
type: 'attachment',
uuid: 'att-1',
timestamp: '2026-10-18T09:41:30.000Z',
isSidechain: false,
attachment: { type: 'queued_command', prompt, source_uuid: 'src-1', commandMode: mode, origin, humanTurn: true },
});
const turns = buildStoryTurns(
entriesOf([
prompt('Build it'),
...bash('b1', 'ls', 'List', 'a'),
queued('Also add a --csv option.', { kind: 'human' }),
...bash('b2', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
end(1000),
])
);
expect(turns).toHaveLength(1);
const page = buildStoryPage(turns[0]);
const added = page.panels.filter((p) => p.kind === 'mission')[1];
expect(added.caption.story).toBe('You add something while Claude works.');
expect(added.balloon?.text).toBe('Also add a --csv option.');
expect(page.events.find((e) => e.id === 'src-1')).toMatchObject({ origin: 'human', actor: 'you' });
});
it("a background helper's report that lands mid-turn is a notice on the wire and joins the helper", () => {
const report = [
'<task-notification>',
'<tool-use-id>a1</tool-use-id>',
'<status>completed</status>',
'<summary>Agent "Docs" finished</summary>',
'<result>README written.</result>',
'</task-notification>',
].join('\n');
const page = pageOf([
prompt('docs and tests'),
tool('a1', 'Agent', { description: 'Docs', subagent_type: 'general-purpose', prompt: 'Write docs' }),
result('a1', 'Async agent launched successfully.', {
toolUseResult: { isAsync: true, status: 'async_launched' },
}),
...bash('b1', 'npm test', 'Run tests', ' Tests 2 passed (2)'),
{
type: 'attachment',
timestamp: '2026-10-18T09:42:00.000Z',
attachment: {
type: 'queued_command',
prompt: report,
commandMode: 'task-notification',
origin: { kind: 'task-notification', producer: 'session-task' },
},
},
said('Both done.'),
end(1000),
]);
expect(page.panels.filter((p) => p.kind === 'mission')).toHaveLength(1);
const notice = page.events.find((e) => e.intent === 'notice')!;
expect(notice).toMatchObject({ tool: 'Report', label: 'Agent "Docs" finished', output: 'README written.' });
expect(page.toolCalls).toBe(2);
expect(page.events[1].helper).toMatchObject({ result: 'README written.' });
expect(page.helpersRunning).toBe(0);
});
it('the Stop hook summary ends a turn that never gets turn_duration, timed by its own span', () => {
const stop = (prevented = false) => ({
type: 'system',
subtype: 'stop_hook_summary',
preventedContinuation: prevented,
timestamp: '2026-10-18T09:45:00.000Z',
});
const [ended] = buildStoryTurns(entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), stop()]));
expect(ended.ended).toBe(true);
expect(ended.durationMs).toBeGreaterThan(200_000); // 09:41:01 to 09:45:00
const [kept] = buildStoryTurns(entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), stop(true)]));
expect(kept.ended).toBe(false);
});
it('a teammate helper (teammate_spawned) runs until its teammate message reports back by name', () => {
const spawned = [
prompt('docs please'),
tool('a1', 'Agent', {
description: 'Write README',
name: 'readme-writer',
subagent_type: 'general-purpose',
prompt: 'Write it',
}),
result('a1', 'Spawned successfully.', {
toolUseResult: { status: 'teammate_spawned', agentId: 'areadme-writer-1', name: 'readme-writer' },
}),
end(1000),
];
expect(pageOf(spawned).events[1].helper).toMatchObject({
background: true,
running: true,
agentId: 'areadme-writer-1',
});
const message = [
'Another Claude session sent a message:',
'<teammate-message teammate_id="readme-writer" color="blue" summary="README done">',
'README written with examples.',
'</teammate-message>',
'<teammate-message teammate_id="readme-writer" color="blue">',
'{"type":"idle_notification","from":"readme-writer","idleReason":"available","result":"I wrote README.md."}',
'</teammate-message>',
'',
'This came from another Claude session.',
].join('\n');
const turns = buildStoryTurns(
entriesOf([...spawned, prompt(message, { turnOrigin: undefined, promptSource: undefined })])
);
expect(turns).toHaveLength(2);
expect(turns[1]).toMatchObject({ origin: 'task', prompt: 'readme-writer: README done' });
const first = buildStoryPage(turns[0]);
expect(first.events[1].helper).toMatchObject({ result: 'I wrote README.md.' });
expect(first.helpersRunning).toBe(0);
});
it('a report written HTML-escaped reads as text', () => {
const [turn] = buildStoryTurns(
entriesOf([
prompt(
'<task-notification>\n<summary>Agent &quot;Docs&quot; finished</summary>\n<result>Co-Authored-By: A &lt;a@b.c&gt; &amp; more</result>\n</task-notification>',
{ turnOrigin: 'task_notification' }
),
])
);
expect(turn.prompt).toBe('Agent "Docs" finished');
expect(turn.events[0].output).toBe('Co-Authored-By: A <a@b.c> & more');
});
it('a check run twice the same way reads once, with a count', () => {
const page = pageOf([
prompt('test twice'),
...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
...bash('b2', 'pytest -q', 'Run tests again', '3 passed in 0.1s'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'check')!.caption.director).toBe('pytest -q → ok ×2 · 3 passed');
});
it('a message from another session names its sender; pasted text loses its wrapper', () => {
const [peer] = buildStoryTurns(
entriesOf([
prompt(
'<cross-session-message from="uds:/x.sock" from-name="planner-2">Rebase first, please.</cross-session-message>',
{
turnOrigin: undefined,
}
),
])
);
expect(peer).toMatchObject({ origin: 'peer', prompt: 'planner-2: Rebase first, please.' });
const [pasted] = buildStoryTurns(
entriesOf([prompt('<pasted_content id="291c">\nDo these steps.\n</pasted_content>')])
);
expect(pasted.prompt).toBe('Do these steps.');
});
it('/compact is its own turn: you run it, Claude packs up, and the command answers itself', () => {
const boundary = { type: 'system', subtype: 'compact_boundary', timestamp: '2026-10-18T09:50:00.000Z' };
const summary = prompt('This session is being continued from a previous conversation.', { isCompactSummary: true });
const command = prompt(
'<command-name>/compact</command-name>\n<command-message>compact</command-message>\n<command-args></command-args>'
);
const stdout = prompt('<local-command-stdout>\u001b[2mCompacted \u001b[22m</local-command-stdout>', {
turnOrigin: undefined,
});
const turns = buildStoryTurns(
entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), end(1000), boundary, summary, command, stdout])
);
expect(turns).toHaveLength(2);
expect(turns[0].events.some((e) => e.intent === 'compact')).toBe(false);
expect(turns[1]).toMatchObject({ prompt: '/compact', ended: true });
const page = buildStoryPage(turns[1]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'compact', 'complete']);
expect(page.panels[0].caption.story).toBe('You run /compact.');
expect(page.events[0]).toMatchObject({ tool: 'Command', output: 'Compacted' });
// A local command like /model ends at its output too.
const [, model] = buildStoryTurns(
entriesOf([
prompt('one'),
end(1000),
prompt('<command-name>/model</command-name><command-args>opus</command-args>'),
prompt('<local-command-stdout>Set model to opus</local-command-stdout>'),
])
);
expect(model).toMatchObject({ prompt: '/model opus', ended: true });
});
it('one prompt written twice (typed /compact, then its command record) is one turn', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('one'),
end(1000),
prompt('/compact', { promptId: 'p2' }),
{ type: 'system', subtype: 'compact_boundary', timestamp: '2026-10-18T09:50:00.000Z' },
prompt('<command-name>/compact</command-name><command-args></command-args>', { promptId: 'p2' }),
prompt('<local-command-stdout>Compacted</local-command-stdout>', { promptId: 'p2' }),
])
);
expect(turns).toHaveLength(2);
const page = buildStoryPage(turns[1]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'compact', 'complete']);
expect(page.panels[0].caption).toEqual({ story: 'You run /compact.', director: 'Command · /compact' });
expect(page.complete).toBe(true);
});
it('plan mode: the plan file is planning, and the plan and your answer come back with ExitPlanMode', () => {
const plan = '# Plan: add wrap\n\n1. Implement wrap.\n2. Add tests.\n3. Commit.';
const page = pageOf([
prompt('plan it'),
...bash('b0', 'ls', 'List', 'a'),
tool('w1', 'Write', { file_path: '/home/me/.claude/plans/radiant.md', content: plan }),
result('w1', 'File created', {
toolUseResult: {
type: 'create',
filePath: '/home/me/.claude/plans/radiant.md',
content: plan,
structuredPatch: [],
},
}),
tool('x1', 'ExitPlanMode', {}),
result('x1', 'User has approved your plan. You can now start coding.', { toolUseResult: { plan } }),
...edit('e1', '/w/a.py', ['+def wrap(): ...']),
end(1000),
]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate', 'plan', 'ask', 'fix', 'complete']);
expect(page.panels[2].todos?.map((t) => t.text)).toEqual(['Implement wrap.', 'Add tests.', 'Commit.']);
expect(page.panels[2].caption.director).toBe('Write · plan file · 3 steps');
expect(page.panels[3].caption.story).toBe('Claude shows you its plan, and you approve it.');
expect(page.events.find((e) => e.tool === 'ExitPlanMode')).toMatchObject({
output: plan,
question: { answer: 'Approved' },
});
expect(page.filesChanged).toBe(1);
});
it('a scheduled task (its prompt a meta line) is a turn of its own, not part of the one before', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('/compact', { promptId: 'p1' }),
prompt('<command-name>/compact</command-name>', { promptId: 'p1' }),
prompt('<local-command-stdout>Compacted</local-command-stdout>', { promptId: 'p1' }),
{
type: 'system',
subtype: 'scheduled_task_fire',
timestamp: '2026-10-18T11:00:00.000Z',
prompt: 'Resume the work',
},
prompt('Resume the work', {
isMeta: true,
turnOrigin: 'scheduled',
promptSource: 'system',
scheduledTaskId: 'abc',
}),
...bash('b', 'ls', 'List', 'a'),
end(5000),
])
);
expect(turns.map((t) => [t.prompt, t.origin, t.endedBy])).toEqual([
['/compact', 'human', 'local command'],
['Resume the work', 'scheduled', 'turn_duration'],
]);
expect(buildStoryPage(turns[1]).panels[0].caption.story).toBe('A scheduled task starts this turn.');
});
it('work long after a turn ended is a turn of its own; a line flushed a moment late still joins', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('one'),
end(1000),
{ ...said('Late line.'), timestamp: undefined },
{
type: 'assistant',
timestamp: '2026-10-18T12:00:00.000Z',
message: { content: [{ type: 'tool_use', id: 'x', name: 'Bash', input: { command: 'ls' } }] },
},
])
);
expect(turns).toHaveLength(2);
expect(turns[1]).toMatchObject({ origin: 'system', prompt: '', ended: false });
expect(turns[1].events.map((e) => e.tool)).toEqual(['Bash']);
});
it('a page with many prompts added mid-turn still fits, folding added prompts last', () => {
const queued = (text: string, i: number) => ({
type: 'attachment',
timestamp: `2026-10-18T09:5${i}:00.000Z`,
attachment: { type: 'queued_command', prompt: text, commandMode: 'prompt', origin: { kind: 'human' } },
});
const lines: unknown[] = [prompt('big job')];
for (let i = 0; i < 8; i++) {
lines.push(queued(`also do ${i}`, i));
lines.push(...edit(`e${i}`, `/w/f${i}.ts`, ['+x']));
lines.push(...bash(`b${i}`, 'npm test', 'Run tests', ' Tests 4 passed (4)'));
}
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.some((p) => /you add \d+ things?/.test(p.caption.story))).toBe(true);
});
it('an automatic compaction after the last turn closes that turn', () => {
const [turn] = buildStoryTurns(
entriesOf([
prompt('one'),
end(1000),
{
type: 'system',
subtype: 'compact_boundary',
timestamp: '2026-10-18T10:00:00.000Z',
compactMetadata: { trigger: 'auto' },
},
])
);
expect(turn.events.map((e) => e.intent)).toEqual(['prompt', 'compact']);
});
it('a command sent to the background is running, not passed, until its notification; its output read later is a check', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('run the slow tests in the background'),
...bash(
'b1',
'sleep 25 && pytest -q',
'Run slow tests',
'Command running in background with ID: bg1. Output is being written to: /tmp/x/tasks/bg1.output'
),
said('The tests are running in the background.'),
end(1000),
prompt(
'<task-notification>\n<task-id>bg1</task-id>\n<tool-use-id>b1</tool-use-id>\n<status>completed</status>\n<summary>Background command \"Run slow tests\" completed (exit code 0)</summary>\n</task-notification>',
{ turnOrigin: 'task_notification' }
),
...bash('b2', 'cat /tmp/x/tasks/bg1.output', 'Read the test output', '7 passed in 25.1s'),
end(1000),
])
);
// Before the report the first turn said it was still running; the report settled it.
const first = buildStoryPage(turns[0]);
expect(first.events[1]).toMatchObject({ intent: 'test', ok: true });
expect(first.events[1].running).toBeUndefined();
// Its drawer says what happened, not the launch note meant for Claude.
expect(first.events[1].output).toBe(
'Finished in the background, exit code 0. Its report is the turn it came back in.'
);
// Not tests that ran during that turn: they ran later, in the background.
expect(first.panels.find((p) => p.kind === 'check')!.caption.story).toBe(
'Claude starts the tests in the background; later they pass.'
);
const second = buildStoryPage(turns[1]);
expect(second.panels.find((p) => p.kind === 'check')).toMatchObject({
caption: { story: 'The tests pass.' },
tests: { passed: 7, failed: 0 },
});
const [alone] = buildStoryTurns(
entriesOf([
prompt('background'),
...bash('b1', 'sleep 25 && pytest -q', 'Run slow tests', 'Command running in background with ID: bg1.'),
end(1000),
])
);
const page = buildStoryPage(alone);
expect(page.events[1]).toMatchObject({ running: true });
expect(page.events[1].ok).toBeUndefined();
expect(page.events[1].output).toBe('Started in the background. Its result comes back as a report of its own.');
expect(page.panels.find((p) => p.kind === 'check')!.caption.story).toBe(
'Claude starts the tests in the background.'
);
expect(page.panels.at(-1)!.caption.story).toBe(
"Claude's part is done in 1 second. A command is still running in the background."
);
});
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 CheckoutButton.tsx and banner.css.');
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: the first run fails, the second passes.');
expect(byKind.save.caption.story).toBe('Claude saves the work: "banner".');
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.' });
// A first sentence this short says too little alone: the next one comes along.
expect(page.panels[page.panels.length - 1].balloon).toEqual({
who: 'claude',
text: 'Fixed. The banner sat on top of the button.',
});
});
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('a caption names a script by its first line that says something (not set -e, cd or S=...)', () => {
const page = pageOf([
prompt('commit'),
...bash('b', 'set -e\nS=/tmp/x\ncd repo\ngit commit -qm "feat: a"', 'Commit', 'ok'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'save')!.caption.director).toBe('git commit -qm "feat: a"');
});
it('a test run that reports failures failed, even when a pipe hid its exit code', () => {
const page = pageOf([
prompt('test it'),
...bash('b', 'pytest -q 2>&1 | tail -3', 'Run tests', '1 failed, 18 passed in 0.31s'),
end(1000),
]);
const check = page.panels.find((p) => p.kind === 'check')!;
expect(check.ok).toBe(false);
expect(check.caption.story).toBe('A test fails.');
expect(check.caption.director).toContain('→ error');
});
it('fix, failing test, fix, passing test folds into one loop with the last word as its balloon', () => {
const page = pageOf([
prompt('add a flag'),
...edit('e1', '/w/a.py', ['+x = 1']),
...bash('b1', 'pytest -q | tail -1', 'Run tests', '1 failed, 3 passed in 0.1s'),
said('The test is right; the flag is off by one.'),
...edit('e2', '/w/a.py', ['-x = 1', '+x = 2']),
...bash('b2', 'pytest -q | tail -1', 'Run tests', '4 passed in 0.1s'),
said('All 4 tests pass now.'),
...bash('b3', 'git commit -qm x', 'Commit', 'ok'),
said('Done.'),
end(1000),
]);
const loop = page.panels.find((p) => p.kind === 'loop')!;
expect(loop.caption.story).toBe('Fix and test: the first run fails, the second passes.');
expect(loop.balloon?.text).toBe('All 4 tests pass now.');
expect(loop.tests).toEqual({ passed: 4, failed: 0 });
});
it('a failed save says so; the failing run is marked failed on the event itself', () => {
const page = pageOf([
prompt('ship'),
...bash('b1', 'pytest -q; echo "exit=$?"', 'Run tests', '1 error in 0.22s\nexit=2'),
...bash('b2', 'git push -u origin main && git -C remote.git log', 'Push', 'fatal: bad object', true),
end(1000),
]);
expect(page.events[1]).toMatchObject({ ok: false, tests: { passed: 0, failed: 0, errors: 1 } });
const check = page.panels.find((p) => p.kind === 'check')!;
expect(check.caption.story).toBe("The tests don't even load.");
expect(check.caption.director).toContain('0 passed, 1 error');
const save = page.panels.find((p) => p.kind === 'save')!;
expect(save.ok).toBe(false);
expect(save.caption.story).toBe('Claude tries to upload the work, and the command fails.');
expect(save.caption.director).toContain('1 error');
});
it('files written through a here-document are edits: on the event, in the counts, out of the label', () => {
const command = [
"cat > index.js <<'EOF'",
'export const add = (a, b) => a + b;',
'test(1 > 2)',
'EOF',
'cat <<EOF > /tmp/scratch.txt',
'scratch',
'EOF',
'node --test && git add -A && git commit -qm "feat: add"',
].join('\n');
const lines = [prompt('write it'), ...bash('b1', command, 'Write, test, commit', '# pass 3\n# fail 0'), end(1000)];
(lines[1] as { cwd?: string }).cwd = '/work/app';
const page = pageOf(lines);
const ev = page.events[1];
expect(ev.intent).toBe('test');
expect(ev.edits?.map((d) => [d.file, d.added])).toEqual([['/work/app/index.js', 2]]);
expect(ev.edits?.[0].hunks[0].lines).toEqual(['+export const add = (a, b) => a + b;', '+test(1 > 2)']);
expect(ev.label).not.toContain('export const add');
expect(ev.headline).toBe('node --test');
expect(page.filesChanged).toBe(1);
const check = page.panels.find((p) => p.kind === 'check')!;
expect(check.caption.director).toBe('node --test → ok · 3 passed · writes 1 file · 1 commit');
// The commit at the end of the test one-liner is not lost, nor the file written: the check says both.
expect(check.caption.story).toBe('Claude writes index.js; the tests pass, and Claude saves the work.');
});
it('one-line printf/echo writes and the files Claude Code says a command changed are edits', () => {
expect(
heredocWrites('printf \'name = "x"\\nversion = 1\\n\' > pyproject.toml && ls', '/w').map((d) => [d.file, d.added])
).toEqual([['/w/pyproject.toml', 2]]);
expect(heredocWrites('echo hi > /tmp/x.txt; echo "a" >> notes.md', '/w').map((d) => d.file)).toEqual([
'/w/notes.md',
]);
const lines = [
prompt('rewrite'),
...bash(
'b1',
"python3 - <<'EOF'\nopen('t.py','w').write('x')\nEOF",
'Rewrite the test file',
"done\n[This command modified 1 file you've previously read: test_a.py. Call Read before editing.]"
),
end(1000),
];
(lines[1] as { cwd?: string }).cwd = '/w';
(lines[2] as { cwd?: string }).cwd = '/w';
const page = pageOf(lines);
// The script's own write and the file Claude Code says changed: both, neither with lines.
expect(page.events[1].edits?.map((d) => [d.file, d.hunks.length])).toEqual([
['/w/t.py', 0],
['/w/test_a.py', 0],
]);
expect(page.filesChanged).toBe(2);
});
it('a commit then a push in its own command is one commit, named; a push alone only uploads', () => {
const save = (lines: unknown[]) => pageOf(lines).panels.find((p) => p.kind === 'save')!.caption.story;
const ship = (to: string) =>
save([
prompt('ship it'),
...bash('b1', 'git commit -qm "feat: add mode()"', 'Commit', ''),
...bash('b2', 'git push origin main', 'Push', `To ${to}\n f80bb31..3e0fb80 main -> main`),
end(1000),
]);
expect(ship('github.com:me/shop.git')).toBe('Claude saves the work and uploads it: "add mode()".');
expect(ship('https://example.com/me/shop.git')).toBe('Claude saves the work and uploads it: "add mode()".');
// A repository in a folder on this computer: a copy, and nothing left the machine.
expect(ship('./origin.git')).toBe(
'Claude saves the work: "add mode()", then copies it to origin.git, a folder on this computer.'
);
expect(ship('/srv/git/shop.git/')).toBe(
'Claude saves the work: "add mode()", then copies it to shop.git, a folder on this computer.'
);
expect(save([prompt('push'), ...bash('b1', 'git push', 'Push', 'Everything up-to-date'), end(1000)])).toBe(
'Claude uploads the work.'
);
expect(pageOf([prompt('c'), ...bash('b1', 'git commit -qm x', 'Commit', ''), end(1000)]).events[1].commits).toBe(1);
});
it('a command whose last check finds nothing (exit code 1 after its answers) is not a failure', () => {
const pageFor = (command: string, output: string) =>
pageOf([prompt('look'), ...bash('b1', command, 'Look', output, true), end(1000)]);
const run = (command: string, output: string) =>
pageFor(command, output).panels.find((p) => p.kind === 'run')!.caption.story;
// It answered its question: the page does not call the job a failure either.
expect(pageFor('cat .env.example | wc -l; git config user.name', 'Exit code 1\n4').noneWorked).toBeUndefined();
expect(pageFor('./deploy.sh; which deploy', 'Exit code 2\nno target').noneWorked).toBe(true);
expect(run('cat .env.example | wc -l; git config user.name', 'Exit code 1\n4')).toBe(
'Claude checks a few things; the last check finds nothing.'
);
expect(run('node scripts/count.js && grep -c TODO src/a.js', 'Exit code 1\n12 files\n0')).toBe(
'Claude checks a few things; the last check finds nothing.'
);
// A real failure, or one with nothing printed before it, still fails.
expect(run('./deploy.sh; which deploy', 'Exit code 2\nno target')).toBe('Claude tries a command, and it fails.');
expect(run('node a.js; grep -q x a.txt', 'Exit code 1')).toBe('Claude tries a command, and it fails.');
expect(run('git config user.name; ./deploy.sh', 'Exit code 1\nme')).toBe('Claude tries a command, and it fails.');
// A usage message or an error from that last step is not an empty answer.
expect(
run('node a.js; git config --nope', "Exit code 1\nok\nerror: unknown option `nope'\nusage: git config")
).toBe('Claude tries a command, and it fails.');
expect(run('node a.js; grep x missing.txt', 'Exit code 1\nok\ngrep: missing.txt: No such file or directory')).toBe(
'Claude tries a command, and it fails.'
);
});
it('a problem in the last words is an open one: not history, not a negation, not a name', () => {
// Open: a catch.
for (const open of [
"Prompts sent through the API didn't submit (separate bug, not fixed).",
'The main problem is RAM: the box has 30 GB.',
"Opening the page: it won't work if you double-click index.html.",
'Limitation: the page does not follow the OS theme until a reload.',
'The bug is still on master.',
])
expect(keyKindOf(open), open).toBe('problem');
// History, a negation or a name: no catch.
for (const closed of [
'I found and fixed a model bug.',
'Bug fixed: the server quietly rewrote a leading slash.',
"The bridge's model-name problem is fixed by infra-guard in commit 19081f6.",
'This also fixes an old bug: an HTTP error used to show the wrong toast.',
'The old sockets were the problem: they lacked that option.',
'The "don\'t write confirmed yet" caveat is gone.',
"The build's link check found no broken links.",
'Not a problem, just not the one-shot I described.',
"I couldn't find a real bug in any of these four.",
'The shutdown path was never broken.',
"It isn't a key problem or a wrong username.",
'SWE-bench is real bug-fixing in large repositories.',
'Requirements are listed at https://example.com/docs#limitations today.',
'A bug fix this round turned up.',
'The memory warning now fires at 90 percent.',
])
expect(['problem'], closed).not.toContain(keyKindOf(closed));
// How things were before, and "still open" as a verb, are no open work.
expect(keyKindOf('Before, that early idle could end a wait while the turn was still running.')).toBeUndefined();
expect(keyKindOf('The old menu used to break when the list was empty.')).toBeUndefined();
expect(keyKindOf('JSON files still open the live log viewer, as intended.')).toBeUndefined();
expect(keyKindOf('Two reviews are still open.')).toBe('pending');
expect(keyKindOf("I sent the request, but the box hasn't answered yet.")).toBe('pending');
expect(keyKindOf('One thing worth flagging: the PR went conflicting because of my push.')).toBe('problem');
// "Left out" is open work, unless Claude chose it.
expect(keyKindOf('The README update is left out for now.')).toBe('pending');
expect(
keyKindOf('Left out on purpose: the search in the working tree belongs to another session.')
).toBeUndefined();
expect(keyKindOf('Anything unknown is left out rather than guessed.')).toBeUndefined();
});
it('a stop names the step Claude was on and what its to-do list still had open', () => {
const stopped = (lines: unknown[]) =>
pageOf([...lines, prompt('[Request interrupted by user]')]).panels.find((p) => p.kind === 'complete')!.caption
.story;
expect(
stopped([
prompt('three things'),
tool('t1', 'TodoWrite', {
todos: [
{ content: 'Split the module', status: 'completed' },
{ content: 'Write the new tests', status: 'in_progress' },
{ content: 'Update the README', status: 'pending' },
{ content: 'Update CONTRIBUTING', status: 'pending' },
],
}),
result('t1', 'Todos have been modified successfully.'),
...bash('b1', 'git commit -qm "split"', 'Commit', ''),
said('Step 2: **new tests** under `test/`. First I work out the values.'),
])
).toBe(
'You stop Claude here, during its step 2 (new tests under test/). Saved before the stop: 1 commit. Not done yet: Write the new tests; Update the README and 1 more.'
);
expect(stopped([prompt('go'), ...bash('b1', 'ls', 'List', 'a')])).toBe('You stop Claude here.');
});
it('the first save in a folder Claude made a repository says so; a bare copy target does not count', () => {
const save = (lines: unknown[]) => pageOf(lines).panels.find((p) => p.kind === 'save')!.caption.story;
expect(
save([
prompt('start'),
...bash('b1', 'git init -q', 'Init', ''),
...edit('e1', '/w/a.py', ['+a = 1']),
...bash('b2', 'git add -A && git commit -qm "add a"', 'Commit', ''),
end(1000),
])
).toBe('Claude starts a git repository and saves the work: "add a".');
// `git init --bare` makes the push target, not the project's repository.
expect(
save([
prompt('start'),
...bash('b1', 'git init -q --bare ../origin.git', 'Remote', ''),
...bash('b2', 'git commit -qm "add a"', 'Commit', ''),
end(1000),
])
).toBe('Claude saves the work: "add a".');
});
it('crafted agent text never takes seconds: every helper stays linear on its worst shapes', () => {
// Each shape took one to several seconds before (a lazy regex rescanning per opener, or a
// backtracking one over a long one-liner); a single call blocks the whole server.
const rep = (str: string, n: number) => str.repeat(Math.ceil(n / str.length)).slice(0, n);
const ms = (fn: () => unknown) => {
const start = performance.now();
fn();
return performance.now() - start;
};
const prompt1 = (content: string) => () => buildStoryTurns(entriesOf([prompt(content), said('ok'), end(1000)]));
const bashOut = (command: string, output: string) => () =>
pageOf([prompt('go'), ...bash('b1', command, 'x', output), end(1000)]);
// At these sizes the old code took 6 to 29 s per shape and the new one takes under 10 ms: the
// bound sits two orders of magnitude from both, so a loaded runner cannot flake it.
const shapes: Array<[string, () => unknown]> = [
['teammate openers', prompt1(rep('<teammate-message teammate_id="a">x ', 1_000_000))],
['system-reminder openers', prompt1('hi ' + rep('<system-reminder>x ', 1_000_000))],
['task-notification field openers', prompt1('<task-notification>\n' + rep('<summary>x ', 1_000_000))],
['git commit tokens', bashOut(rep('git commit ', 16_000), 'ok')],
[
'modified-note phrases',
bashOut('python3 x.py', rep("This command modified 1 file you've previously read: a", 1_000_000)),
],
];
for (const [name, fn] of shapes) expect([name, ms(fn) < 1500]).toEqual([name, true]);
});
it('a turn of many questions still keeps to 12 panels: the oldest questions fold last', () => {
const lines: unknown[] = [prompt('ask me a lot')];
for (let i = 0; i < 14; i++) {
lines.push(
tool(`q${i}`, 'AskUserQuestion', { questions: [{ question: `Question ${i}?`, options: [{ label: 'Yes' }] }] }),
result(`q${i}`, 'ok', { toolUseResult: { answers: { [`Question ${i}?`]: 'Yes' } } }),
...read(`r${i}`, `/w/f${i}.ts`)
);
}
lines.push(end(1000));
const page = pageOf(lines);
expect(page.panels.length).toBeLessThanOrEqual(12);
expect(page.panels[0].kind).toBe('mission');
expect(page.panels.at(-1)!.kind).toBe('complete');
});
it('the closing balloon keeps an open problem and a question to you from far into the message, never a table row', () => {
const long = [
'I built the csvstat tool for your CSV files.',
'It reads the file and prints the counts for each column, one line per column. '.repeat(4),
'| column | state |',
'| amount | not yet rounded |',
'Want me to add a --json flag?',
'One known issue: big numbers are shown rounded.',
].join('\n');
const page = pageOf([prompt('build it'), said(long), end(1000)]);
// In the order Claude wrote them; the question is the sentence aimed at you, so it stays.
expect(page.panels.at(-1)!.balloon!.text).toBe(
'I built the csvstat tool for your CSV files. Want me to add a --json flag? One known issue: big numbers are shown rounded.'
);
expect(page).toMatchObject({ openNote: 'catch', asks: true });
});
it('the closing balloon keeps the outcome, the items a list announces and what an offer points back to (round 10)', () => {
const last = (text: string) => pageOf([prompt('go'), said(text), end(1000)]).panels.at(-1)!.balloon!.text;
const filler = 'It reads the file and prints the counts for each column, one line per column. '.repeat(3);
// The outcome after the first sentence, and a test count ("1 failed") is no failed step.
expect(
last(
[
'I added a fifth test.',
'All 5 tests now pass, and the change is committed.',
filler,
'- First run: the result was 4 passed, 1 failed.',
].join('\n')
)
).toBe('I added a fifth test. All 5 tests now pass, and the change is committed.');
// "Two places…:" brings both list lines, not only the first.
expect(
last(
[
'I ran all 16 steps in order, one tool call each.',
filler,
"Two places where I didn't follow your wording exactly:",
'- Step 6: there is no Grep tool, so I ran grep through Bash. It found the same matches.',
'- Steps 5 and 10: I added a co-author line to each commit.',
].join('\n')
)
).toBe(
"I ran all 16 steps in order, one tool call each. Two places where I didn't follow your wording exactly: Step 6: there is no Grep tool, so I ran grep through Bash. It found the same matches. Steps 5 and 10: I added a co-author line to each commit."
);
// An offer whose "it" is further back brings the start of its paragraph.
expect(
last(
[
'I added a --prefix option.',
filler,
'Suffix swap: the two files with the same time would trade their _1 suffix. This was already in the script before. Sorting byte by byte would fix it. Want me to add that?',
].join('\n')
)
).toBe(
'I added a --prefix option. Suffix swap: the two files with the same time would trade their _1 suffix. … Sorting byte by byte would fix it. Want me to add that?'
);
// A decision left to you is something for you.
expect(keyKindOf('Decisions you may want to change: I committed CLAUDE.md.')).toBe('offer');
});
it('a check after a push is named a check, and tests in a fresh copy say so', () => {
const page = pageOf([
prompt('ship it'),
...bash('b1', 'git commit -qm "add a"', 'Commit', ''),
...bash(
'b2',
'git push -u origin main && git remote -v && git -C remote.git log --oneline',
'Push',
'Exit code 128\nTo ./remote.git\n * [new branch] main -> main\nfatal: your current branch does not have any commits yet',
true
),
...bash(
'b3',
'C=/tmp/x && git clone -q remote.git "$C" && (cd "$C" && python3 -m pytest -q)',
'Fresh copy',
'19 passed in 0.11s'
),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'save')!.caption.story).toMatch(/, but a check after that fails\.$/);
expect(page.panels.find((p) => p.kind === 'check')!.caption.story).toBe(
'The tests pass in a fresh copy made from remote.git.'
);
});
it("a run panel's terminal card shows what the command printed, never what looks like a secret", () => {
const page = pageOf([
prompt('dry run please'),
...bash(
'b1',
'./rename.sh --dry-run samples/',
'Dry run',
'a.jpg -> holiday_2024-05-01.jpg\nAPI_TOKEN=abc123\nauthor: me@example.com\nb.jpg -> holiday_2024-05-02.jpg'
),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'run')!.printed).toEqual([
'a.jpg -> holiday_2024-05-01.jpg',
'author: …@…',
'b.jpg -> holiday_2024-05-02.jpg',
]);
});
it('a helper messaged twice reads as one finished run, not as superseded', () => {
const r = createStoryReducer({ sidechain: true });
const lines = [prompt('write it'), said('Done.'), prompt('also the tests'), said('Done too.')].map((l) => ({
...(l as object),
isSidechain: true,
}));
const page = buildHelperPage(buildStoryTurns(lines.flatMap((l) => r.reduce(l))), true)!;
expect(page.superseded).toBeUndefined();
expect(page.panels.at(-1)!.caption.story).not.toMatch(/trail stops here/);
});
it('a python or node script fed by a here-document: the files it opens for writing, never guessed', () => {
const files = (command: string) => heredocWrites(command, '/w').map((d) => d.file);
expect(
files(
[
"python3 - <<'EOF'",
'p = "textstats.py"',
's = open(p).read()',
"s = s.replace('a', 'b')",
'open(p, "w").write(s)',
"Path('README.md').write_text('# x')",
"with open('notes.txt', mode='a') as f: f.write('x')",
"open('/tmp/scratch.txt', 'w').write('x')",
"open(os.path.join(d, 'x.py'), 'w')",
"open('data.json').read()",
'EOF',
].join('\n')
)
).toEqual(['/w/textstats.py', '/w/README.md', '/w/notes.txt']);
expect(files("node <<'EOF'\nconst out = 'dist/app.js';\nfs.writeFileSync(out, code);\nEOF")).toEqual([
'/w/dist/app.js',
]);
expect(files("python3 - <<'EOF'\nprint(open('a.py').read())\nEOF")).toEqual([]);
const [edit] = heredocWrites("python3 - <<'EOF'\nopen('a.py', 'w').write('x')\nEOF", '/w');
expect(edit).toMatchObject({ file: '/w/a.py', added: 0, removed: 0, hunks: [], truncated: true });
// A script that writes project files is an edit: it draws as a change, not as terminal work.
const lines = [
prompt('rewrite'),
...bash('b1', "python3 - <<'EOF'\nopen('a.py', 'w').write('x')\nEOF", 'Rewrite a.py', ''),
end(1000),
];
(lines[1] as { cwd?: string }).cwd = '/w';
expect(pageOf(lines).panels.map((p) => p.kind)).toEqual(['mission', 'fix', 'complete']);
});
it('a compaction says what it did; ToolSearch is looking; EnterPlanMode says plan mode', () => {
const page = pageOf([
prompt('go'),
tool('t1', 'ToolSearch', { query: 'select:EnterPlanMode,ExitPlanMode', max_results: 2 }),
result('t1', 'ok'),
tool('p1', 'EnterPlanMode', {}),
result('p1', 'Entered plan mode.'),
{
type: 'system',
subtype: 'compact_boundary',
timestamp: '2026-10-18T09:45:00.000Z',
compactMetadata: { trigger: 'auto', preTokens: 46928, postTokens: 3623, durationMs: 24014 },
},
end(1000),
]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate', 'plan', 'compact', 'complete']);
expect(page.panels[2].caption.story).toBe('Claude switches to plan mode to think it through.');
expect(page.panels[3].caption).toEqual({
story: "Claude's memory is full: it packs up its notes.",
director: 'automatic · 46.9k → 3.6k tokens · 24 s',
});
});
it('heredocWrites skips scratch, stdin bodies and relative paths after a cd', () => {
expect(heredocWrites("python3 - <<'EOF'\nprint(1)\nEOF", '/w')).toEqual([]);
expect(heredocWrites('cat > "$S/x.txt" <<EOF\nx\nEOF', '/w')).toEqual([]);
expect(heredocWrites('cd sub && cat > a.txt <<EOF\nx\nEOF', '/w').map((d) => d.file)).toEqual(['a.txt']);
expect(heredocWrites('tee -a notes.md <<EOF\nx\nEOF', '/w').map((d) => d.file)).toEqual(['/w/notes.md']);
});
it('an append has no line numbers it cannot know; a whole-file write starts at line 1', () => {
const [append] = heredocWrites("cat >> notes.md <<'EOF'\none\ntwo\nEOF", '/w');
expect(append.hunks[0]).toMatchObject({ atEnd: true, newStart: 0, newLines: 2 });
const [echoed] = heredocWrites('echo "more" >> notes.md', '/w');
expect(echoed.hunks[0]).toMatchObject({ atEnd: true, newStart: 0 });
const [whole] = heredocWrites("cat > notes.md <<'EOF'\none\nEOF", '/w');
expect(whole.hunks[0]).toMatchObject({ newStart: 1, newLines: 1 });
expect(whole.hunks[0].atEnd).toBeUndefined();
});
it('sed -n prints part of a file, like head', () => {
expect(printedFiles("sed -n '5,25p' README.md")).toEqual(['README.md']);
expect(printedFiles('sed -n 10p src/a.py && ls')).toEqual(['src/a.py']);
expect(printedFiles("sed -i 's/a/b/' a.py")).toEqual([]);
});
it('files a command prints are on its event, resolved against its cwd, whatever it does', () => {
const lines = [
prompt('rewrite and test'),
...bash('b1', 'cat textstats.py && python3 -m pytest -q', 'Show and test', 'def f(): pass\n5 passed in 0.1s'),
end(1000),
];
(lines[1] as { cwd?: string }).cwd = '/w';
const page = pageOf(lines);
expect(page.events[1]).toMatchObject({ intent: 'test', files: ['/w/textstats.py'] });
});
it('words that start a turn but are not yours are a note, not your balloon', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('go'),
end(1000),
prompt(
'<task-notification>\n<task-id>bg1</task-id>\n<status>completed</status>\n<summary>Background command \"Run tests\" completed (exit code 0)</summary>\n</task-notification>',
{ turnOrigin: 'task_notification' }
),
said('All good.'),
end(1000),
])
);
expect(buildStoryPage(turns[0]).panels[0].balloon?.who).toBe('you');
const report = buildStoryPage(turns[1]).panels[0];
expect(report.caption.story).toBe('A background job reports back.');
expect(report.balloon?.who).toBe('note');
});
it('a loop keeps every run: a lint failure inside a round that passed in the end still shows', () => {
const page = pageOf([
prompt('build'),
...edit('e1', '/w/a.py', ['+a = 1']),
...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
...edit('e2', '/w/b.py', ['+b = 1']),
...bash(
'b2',
'pytest -q; ruff format --check .',
'Run tests and format check',
'6 passed in 0.1s\n1 file would be reformatted'
),
...bash('b3', 'ruff format . && pytest -q', 'Format and retest', '1 file reformatted\n6 passed in 0.1s'),
end(1000),
]);
const loop = page.panels.find((p) => p.kind === 'loop')!;
expect(loop.roundsOk).toEqual([true, false, true]);
expect(loop.caption.story).toBe(
'Build and test, 2 rounds. The tests pass every time; a layout check fails once, then passes.'
);
});
it('a live loop marks the runs that finished, and the one still going as null', () => {
const page = pageOf([
prompt('fix'),
...edit('e1', '/w/a.py', ['+a = 1']),
...bash('b1', 'pytest -q', 'Run tests', '1 failed, 2 passed in 0.1s'),
...edit('e2', '/w/a.py', ['-a = 1', '+a = 2']),
tool('b2', 'Bash', { command: 'pytest -q', description: 'Run tests again' }),
]);
expect(page.panels.find((p) => p.kind === 'loop')?.roundsOk).toEqual([false, null]);
});
it("Claude's word about a red run stays on that run's check", () => {
const page = pageOf([
prompt('test first'),
...edit('e1', '/w/test_a.py', ['+import a']),
...bash('b1', 'pytest -q', 'Run tests', 'ERROR test_a.py\n1 error in 0.1s'),
said('Red as expected: collection error, exit code 2.'),
...bash('b2', 'git commit -qm "test: first"', 'Commit', ''),
end(1000),
]);
const check = page.panels.find((p) => p.kind === 'check' || p.kind === 'loop')!;
expect(check.balloon?.text).toBe('Red as expected: collection error, exit code 2.');
expect(page.panels.find((p) => p.kind === 'save')!.balloon).toBeUndefined();
});
it('checks that all pass from the start read as building, not fixing', () => {
const page = pageOf([
prompt('build it'),
...edit('e1', '/w/a.py', ['+a = 1']),
...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
...edit('e2', '/w/b.py', ['+b = 1']),
...bash('b2', 'pytest -q', 'Run tests', '6 passed in 0.1s'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'loop')!.caption.story).toBe(
'Build and test, 2 rounds. Every check passes.'
);
});
it('a second opinion in front of two helpers neither splits them nor counts as one', () => {
const advisor = {
type: 'assistant',
timestamp: '2026-10-18T09:41:20.000Z',
message: {
content: [
{ type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} },
{ type: 'advisor_tool_result', tool_use_id: 'srv1', content: { type: 'advisor_redacted_result' } },
],
},
};
const page = pageOf([
prompt('go'),
advisor,
tool('a1', 'Agent', { description: 'A', subagent_type: 'general-purpose', prompt: 'x' }),
result('a1', 'done A'),
tool('a2', 'Agent', { description: 'B', subagent_type: 'general-purpose', prompt: 'y' }),
result('a2', 'done B'),
end(1000),
]);
const backups = page.panels.filter((p) => p.kind === 'backup');
expect(backups).toHaveLength(1);
expect(backups[0]).toMatchObject({ secondOpinion: 1, caption: { story: 'Claude calls in 2 helpers.' } });
expect(backups[0].helpers?.map((h) => h.task)).toEqual(['A', 'B']);
});
it('the advisor (a server tool) is a helper giving a second opinion', () => {
const page = pageOf([
prompt('think hard'),
{
type: 'assistant',
timestamp: '2026-10-18T09:41:20.000Z',
message: {
content: [
{ type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} },
{
type: 'advisor_tool_result',
tool_use_id: 'srv1',
content: { type: 'advisor_redacted_result', encrypted_content: 'xx' },
},
],
},
},
end(1000),
]);
// Not a step of its own: it rides on the panel before it, here the mission (nothing follows).
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'complete']);
expect(page.panels[0]).toMatchObject({ secondOpinion: 1 });
expect(page.panels[0].caption.director).toMatch(/then 1 second opinion$/);
expect(page.events[1]).toMatchObject({ tool: 'Advisor', ok: true, helper: { name: 'advisor' } });
expect(page.events[1].helper!.result).toMatch(/encrypted/);
expect(page.helpers).toBe(0);
});
it('a turn left open when the next prompt starts has ended; an interrupted turn is timed', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('one'),
...bash('b', 'ls', 'List', 'a'),
prompt('two'),
...bash('c', 'ls', 'List', 'a'),
{ type: 'user', timestamp: '2026-10-18T09:50:00.000Z', message: { content: '[Request interrupted by user]' } },
])
);
expect(turns[0]).toMatchObject({ ended: true, interrupted: false, endedBy: 'next prompt' });
const superseded = buildStoryPage(turns[0]);
expect(superseded.superseded).toBe(true);
expect(superseded.panels[superseded.panels.length - 1].caption.story).toBe(
'The trail stops here: the next prompt starts a new turn.'
);
expect(superseded.panels[superseded.panels.length - 1].caption.director).toMatch(
/^superseded by the next prompt · /
);
expect(turns[0].durationMs).toBeGreaterThan(0);
expect(turns[1]).toMatchObject({ ended: true, interrupted: true });
expect(turns[1].durationMs).toBeGreaterThan(0);
});
it('a remark about committing stays on the save it reports on', () => {
const page = pageOf([
prompt('commit then test'),
...bash('b1', 'git commit -qm a', 'Commit', 'ok'),
said('Commits are in.'),
...bash('b2', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'save')!.balloon?.text).toBe('Commits are in.');
expect(page.panels.find((p) => p.kind === 'check')!.balloon).toBeUndefined();
});
it('a result remark replaces a step announcement on its check', () => {
const page = pageOf([
prompt('steps'),
said('Step 1: split the module.'),
...bash('b1', 'node --test', 'Run tests', '# pass 8\n# fail 0'),
said('All 8 tests pass.'),
...bash('b2', 'git commit -qm x', 'Commit', 'ok'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'check')!.balloon?.text).toBe('All 8 tests pass.');
expect(page.panels.find((p) => p.kind === 'save')!.balloon).toBeUndefined();
});
it('a check whose output reports lint problems failed, though a trailing ls hid the exit code', () => {
const page = pageOf([
prompt('check'),
...bash(
'b1',
'pytest -q | tail -1; ruff format --check .; ls',
'Tests and format',
'6 passed in 0.1s\n1 file would be reformatted\nREADME.md'
),
end(1000),
]);
const check = page.panels.find((p) => p.kind === 'check')!;
expect(check.ok).toBe(false);
expect(check.caption.story).toBe('The tests pass, but another check fails.');
});
it('a save counts its commits, and a montage names what it folded, commits included', () => {
const page = pageOf([
prompt('commit'),
...bash('b1', 'git add a && git commit -qm a && git add b && git commit -qm b && git push', 'Commit twice', 'ok'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'save')!.caption.story).toBe(
'Claude saves the work in 2 commits and uploads it.'
);
const lines: unknown[] = [prompt('many steps')];
for (let i = 0; i < 14; i++) {
lines.push(...read(`r${i}`, `/w/f${i}.ts`));
lines.push(...bash(`c${i}`, `git commit -qm step${i}`, `Commit ${i}`, 'ok'));
}
lines.push(end(60_000));
const long = pageOf(lines);
const montages = long.panels.filter((p) => p.kind === 'montage');
expect(montages.length).toBeGreaterThan(0);
expect(montages.some((p) => /saves the work/.test(p.caption.story))).toBe(true);
// The newest panels keep their place: folding starts at the oldest.
expect(long.panels[long.panels.length - 2].kind).not.toBe('montage');
});
it('a loop ends at its last check; writes after it are a panel of their own', () => {
const page = pageOf([
prompt('tdd'),
...edit('e1', '/w/test_a.py', ['+def test_a(): ...']),
...bash('b1', 'pytest -q', 'Run tests', '1 failed in 0.1s'),
...edit('e2', '/w/a.py', ['+a = 1']),
...bash('b2', 'pytest -q', 'Run tests', '1 passed in 0.1s'),
...edit('e3', '/w/README.md', ['+# a']),
end(1000),
]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'loop', 'fix', 'complete']);
expect(page.panels[2].files).toEqual(['/w/README.md']);
});
it('what Claude says about a check that just ran stays on that check', () => {
const page = pageOf([
prompt('test then commit'),
...bash('b1', 'node --test', 'Run tests', '# pass 7\n# fail 0'),
said('All 7 tests pass.'),
...bash('b2', 'git commit -qm x', 'Commit', 'ok'),
said("Now I'll push it."),
...bash('b3', 'ls', 'List', 'a'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'check')!.balloon?.text).toBe('All 7 tests pass.');
expect(page.panels.find((p) => p.kind === 'save')!.balloon).toBeUndefined();
expect(page.panels.find((p) => p.kind === 'investigate')!.balloon?.text).toBe("Now I'll push it.");
});
it('a numbered list Claude writes before working is a plan panel; files it cats are file cards', () => {
const page = pageOf([
prompt('plan first'),
said('Todo list:\n\n1. Look around.\n2. Write the script.\n3. Test it.'),
...bash('b1', 'cat README.md && ls', 'Read the readme', '# demo'),
...edit('e1', '/w/a.sh', ['+echo hi']),
said('Summary:\n1. one\n2. two\n3. three'),
end(1000),
]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'plan', 'investigate', 'fix', 'complete']);
const plan = page.panels[1];
expect(plan.todos?.map((t) => t.text)).toEqual(['Look around.', 'Write the script.', 'Test it.']);
expect(plan.caption.director).toBe('written as text · 3 steps');
expect(page.panels[2].files).toEqual(['README.md']);
});
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');
});
});