mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
0ab71d61d1
commit
2653db10b5
27 files changed
+5746
-6
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user