From 848566828fbfe3c009e3b4e29f458c67bf3e9d76 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 10:41:14 +0200 Subject: [PATCH] fix(story-mode): read how current Claude Code builds deliver prompts and end turns Found by replaying round-2 review transcripts while the reviews ran, then surveyed across 150 local transcripts: - A prompt typed while Claude works writes no user line in current builds, only a queued_command attachment where Claude took it in (602 of them locally, 485 of those background-task reports). It now joins the running turn; a report that lands mid-turn is a notice on the wire, not a new mission panel, and a background helper's answer joins back from it. - Some sessions never write turn_duration (13 of 135 locally): the Stop hook's stop_hook_summary (never preventedContinuation in 815 seen) now ends the turn, timed by its own span. Before, such a session's turns never ended. - A helper started as a teammate (teammate_spawned) is a background helper; its reports (text or a JSON idle notification) join it by name, and the turn they start reads "readme-writer: README done". - A names its sender; pasted text loses Claude Code's wrapper (it showed in balloons and the picker). - The mission panel, its balloon and its drawer follow who sent each prompt (you, a background job, another session), not who started the turn. - A caption names a script by its first line that says something, not `set -e`, a bare `cd` or `S=/tmp/...`. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/story-mode.ts | 194 +++++++++++++++++++++++++++++++---- src/types/story.ts | 3 + src/web/public/story-mode.js | 27 ++++- test/story-mode.test.ts | 143 ++++++++++++++++++++++++++ 5 files changed, 343 insertions(+), 26 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e8b56811..289e91d7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1067,7 +1067,7 @@ Responses carry a weak `ETag` with `Cache-Control: no-cache`; a request with a m A `page` carries the turn's summary fields plus `events` (one per prompt, sentence Claude wrote and tool call, with `intent`, `ok` when the call returned, `edit` hunks in Claude Code's own `structuredPatch` shape, the tail of `output`, `helper`, `tests` counts, and `files` for the files a Bash call printed with `cat`/`head`/`tail`, as the command named them), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `helpersRunning`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). A transcript that cannot be read is a `500` with `OPERATION_FAILED`. -A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. +A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. ## Git worktrees of a case diff --git a/src/story-mode.ts b/src/story-mode.ts index 4239086c..b08a56b3 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -107,6 +107,8 @@ export type StoryEntry = queued: boolean; /** A background task reporting back: which call it answers, and how it went. */ task?: StoryTaskReport; + /** Teammates (helpers started as a team) reporting back, matched to their helper by name. */ + teammates?: StoryTeammateReport[]; } | { k: 'text'; at: string; text: string } | { k: 'tool'; at: string; id: string; name: string; input: ReducedInput } @@ -135,6 +137,13 @@ interface StoryTaskReport { durationMs?: number; } +/** One ``: a helper started as a teammate, reporting back by name. */ +interface StoryTeammateReport { + from: string; + summary?: string; + result?: string; +} + // ========== Small helpers ========== const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); @@ -465,11 +474,61 @@ const SYSTEM_REMINDER_RE = /[\s\S]*?<\/system-reminder>/g; const LOCAL_WRAPPER_RE = /^\s*<(local-command-caveat|local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr)>/; -function readPrompt( - raw: string -): { text: string; kind: 'prompt' | 'task' | 'interrupt'; task?: StoryTaskReport } | null { - const text = raw.replace(SYSTEM_REMINDER_RE, '').trim(); +/** + * Messages from teammates: `body`, + * where the body is text or a JSON notification (`{"type":"idle_notification","result":"..."}`). + */ +function readTeammateMessages(text: string): StoryTeammateReport[] { + const reports: StoryTeammateReport[] = []; + for (const m of text.matchAll(/]*)>([\s\S]*?)<\/teammate-message>/g)) { + const from = /teammate_id="([^"]*)"/.exec(m[1])?.[1]?.split('@')[0]; + if (!from) continue; + const summary = /summary="([^"]*)"/.exec(m[1])?.[1]; + let body: string | undefined = m[2].trim(); + if (body.startsWith('{')) { + try { + const note: unknown = JSON.parse(body); + body = isRecord(note) ? (str(note.result) ?? str(note.summary) ?? str(note.message)) : undefined; + } catch { + // not JSON after all: keep it as text + } + } + reports.push({ + from: clip(from, 80), + summary: summary ? clip(summary, 200) : undefined, + result: body || undefined, + }); + } + return reports; +} + +function readPrompt(raw: string): { + text: string; + kind: 'prompt' | 'task' | 'peer' | 'interrupt'; + task?: StoryTaskReport; + teammates?: StoryTeammateReport[]; +} | null { + const text = raw + .replace(SYSTEM_REMINDER_RE, '') + .replace(/<\/?pasted_content\b[^>]*>/g, '') // Claude Code's wrapper around pasted text + .replace(/^\s*Another Claude session sent a message:\s*/, '') + .trim(); if (!text) return null; + if (/^ t.summary || t.result) ?? teammates[0]; + if (first) + return { + text: `${first.from}: ${first.summary || (first.result ? firstSentence(first.result, 200) : 'reports back')}`, + kind: 'task', + teammates, + }; + } + const peer = /^]*)>([\s\S]*?)(?:<\/cross-session-message>|$)/.exec(text); + if (peer) { + const name = /from-name="([^"]*)"/.exec(peer[1])?.[1]; + return { text: `${name ? `${name}: ` : ''}${peer[2].trim()}`, kind: 'peer' }; + } if (/^\[Request interrupted/.test(text)) return { text, kind: 'interrupt' }; if (LOCAL_WRAPPER_RE.test(text)) return null; const command = /([^<]*)<\/command-name>/.exec(text); @@ -495,10 +554,10 @@ function readPrompt( return { text, kind: 'prompt' }; } -function originOf(turnOrigin: unknown, kind: 'prompt' | 'task'): StoryTurnOrigin { +function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryTurnOrigin { + if (turnOrigin === 'task_notification' || turnOrigin === 'task-notification' || kind === 'task') return 'task'; + if (turnOrigin === 'peer' || turnOrigin === 'coordinator' || kind === 'peer') return 'peer'; if (turnOrigin === 'human') return 'human'; - if (turnOrigin === 'task_notification' || kind === 'task') return 'task'; - if (turnOrigin === 'peer') return 'peer'; if (typeof turnOrigin === 'string') return 'system'; return 'human'; } @@ -688,7 +747,8 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean } if (name === 'Agent' || name === 'Task') { // A background start answers with launch notes meant for Claude only, never the helper's answer. - const background = extra.isAsync === true || extra.status === 'async_launched'; + const background = + extra.isAsync === true || extra.status === 'async_launched' || extra.status === 'teammate_spawned'; const answer = background ? '' : blockText(extra.content) || text; result.helper = { background: background || undefined, @@ -721,13 +781,47 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean const prompt = readPrompt(blockText(content)); if (!prompt) return out; if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }]; + return [ + promptEntry( + prompt, + str(entry.uuid) ?? `prompt-${at}`, + at, + originOf(entry.turnOrigin, prompt.kind), + entry.promptSource === 'queued' + ), + ]; + } + + /** + * A prompt that arrived while Claude worked: current builds write no user line for it, only an + * `attachment` of type `queued_command` where Claude took it in (a typed prompt, a background + * task's notification, a message from another session). + */ + function reduceQueuedCommand(entry: Record, at: string): StoryEntry[] { + const a = isRecord(entry.attachment) ? entry.attachment : {}; + if (a.type !== 'queued_command') return []; + const prompt = readPrompt(typeof a.prompt === 'string' ? a.prompt : blockText(a.prompt)); + if (!prompt || prompt.kind === 'interrupt') return []; + const from = isRecord(a.origin) ? a.origin.kind : undefined; + const kind = a.commandMode === 'task-notification' && prompt.kind === 'prompt' ? 'task' : prompt.kind; + const id = str(a.source_uuid) ?? str(entry.uuid) ?? `queued-${at}`; + return [promptEntry(prompt, id, at, originOf(from ?? 'human', kind), true)]; + } + + function promptEntry( + prompt: NonNullable>, + id: string, + at: string, + origin: StoryTurnOrigin, + queued: boolean + ): Extract { const reduced: Extract = { k: 'prompt', - id: str(entry.uuid) ?? `prompt-${at}`, + id, at, text: clip(prompt.text, STORY_LIMITS.promptChars), - origin: originOf(entry.turnOrigin, prompt.kind), - queued: entry.promptSource === 'queued', + origin, + queued, }; if (prompt.task) { const t = prompt.task; @@ -739,7 +833,13 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean durationMs: t.durationMs, }; } - return [reduced]; + if (prompt.teammates?.length) + reduced.teammates = prompt.teammates.slice(0, 8).map((t) => ({ + from: t.from, + summary: t.summary, + result: t.result ? clip(t.result, STORY_LIMITS.helperChars) : undefined, + })); + return reduced; } function reduceAssistant(entry: Record, at: string): StoryEntry[] { @@ -771,8 +871,13 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean return reduceUser(line, at); case 'assistant': return reduceAssistant(line, at); + case 'attachment': + return reduceQueuedCommand(line, at); case 'system': if (line.subtype === 'turn_duration') return [{ k: 'end', at, durationMs: num(line.durationMs) }]; + // Some sessions never write turn_duration; the Stop hook's summary marks the same end (unless + // the hook kept Claude going). + if (line.subtype === 'stop_hook_summary' && line.preventedContinuation !== true) return [{ k: 'end', at }]; if (line.subtype === 'compact_boundary') return [{ k: 'compact', at }]; return []; default: @@ -930,7 +1035,25 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { const byId = new Map(); /** Helper calls by tool id, across turns: a background helper reports back in a later turn. */ const helpers = new Map(); + /** The same helpers by name: a teammate reports back by name, not by call. */ + const helpersByName = new Map(); let say = 0; + const teammatesBack = (reports: StoryTeammateReport[], prompt: StoryEvent) => { + const told = reports.filter((r) => r.result || r.summary); + if (told.length) { + prompt.output = clip( + told.map((r) => `${r.from}: ${r.result ?? r.summary}`).join('\n\n'), + STORY_LIMITS.helperChars + ); + } + for (const r of reports) { + const ev = helpersByName.get(r.from); + if (!ev?.helper) continue; + const helper: StoryHelper = { ...ev.helper, running: undefined }; + if (r.result) helper.result = r.result; + ev.helper = helper; + } + }; const reportBack = (report: StoryTaskReport, prompt: StoryEvent) => { if (report.result) { prompt.output = report.result; @@ -964,9 +1087,23 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { for (const e of entries) { switch (e.k) { case 'prompt': { - const ev: StoryEvent = { id: e.id, at: e.at, actor: 'you', tool: 'Prompt', label: e.text, intent: 'prompt' }; + const ev: StoryEvent = { + id: e.id, + at: e.at, + actor: e.origin === 'human' ? 'you' : 'system', + tool: 'Prompt', + label: e.text, + intent: 'prompt', + origin: e.origin, + }; if (e.task) reportBack(e.task, ev); + if (e.teammates) teammatesBack(e.teammates, ev); if (cur && !cur.ended && e.queued) { + // A report that lands while Claude works is a line on the wire, not a new mission. + if (e.origin === 'task') { + ev.intent = 'notice'; + ev.tool = 'Report'; + } cur.events.push(ev); } else { cur = { @@ -1001,6 +1138,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { if (ev.helper) { helpers.set(e.id, ev); if (helpers.size > 500) helpers.delete(helpers.keys().next().value as string); + helpersByName.set(ev.helper.name, ev); + if (helpersByName.size > 500) helpersByName.delete(helpersByName.keys().next().value as string); } break; } @@ -1017,9 +1156,9 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { // Claude Code times a turn a background task started from the prompt that started the chain; // a turn never takes longer than its own span, so that is the cap. const span = Date.parse(e.at) - Date.parse(t.at); - if (e.durationMs !== undefined) - t.durationMs = - !t.partial && Number.isFinite(span) && span >= 0 ? Math.min(e.durationMs, span) : e.durationMs; + const known = !t.partial && Number.isFinite(span) && span >= 0; + if (e.durationMs !== undefined) t.durationMs = known ? Math.min(e.durationMs, span) : e.durationMs; + else if (known && t.durationMs === undefined) t.durationMs = span; // ended by the Stop hook's summary } break; } @@ -1053,7 +1192,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { return turns; } -const NOT_TOOL_CALLS = new Set(['prompt', 'say', 'interrupt', 'compact']); +const NOT_TOOL_CALLS = new Set(['prompt', 'say', 'interrupt', 'compact', 'notice']); const countToolCalls = (events: StoryEvent[]) => events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)).length; export function summarizeTurn(turn: StoryTurn): StoryTurnSummary { @@ -1089,6 +1228,7 @@ const PANEL_FOR: Record = { ask: 'ask', compact: 'compact', interrupt: null, + notice: null, }; /** Kinds whose consecutive events share one panel. A prompt or a question always gets its own. */ const MERGEABLE = new Set([ @@ -1244,7 +1384,13 @@ function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string { return [...counts].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(' · '); } -const shortCommand = (e: StoryEvent) => clip(e.label.split('\n')[0] ?? '', 60); +/** Lines of a script that say nothing about what it does: `set -e`, a bare `cd dir`, `S=/tmp/x`, comments. */ +const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/; +/** A command's first line that says what it does, for a caption. */ +const shortCommand = (e: StoryEvent) => { + const lines = e.label.split('\n'); + return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60); +}; function montageSummary(evs: StoryEvent[]): string { const parts: string[] = []; @@ -1292,8 +1438,16 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde peer: 'Another session sends a message.', system: 'Something automatic starts this turn.', }; - story = index > 0 ? 'You add something while Claude works.' : lines[turn.origin]; - director = `Prompt · ${plural(words, 'word')}${turn.origin === 'human' ? '' : ` · ${turn.origin}`}`; + const origin = evs[0]?.origin ?? turn.origin; + story = + index === 0 + ? lines[turn.origin] + : origin === 'human' + ? 'You add something while Claude works.' + : origin === 'peer' + ? 'Another session chimes in while Claude works.' + : lines[origin]; + director = `Prompt · ${plural(words, 'word')}${origin === 'human' ? '' : ` · ${origin}`}`; break; } case 'investigate': { diff --git a/src/types/story.ts b/src/types/story.ts index d9b80cba..46b0a89d 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -29,6 +29,7 @@ export type StoryIntent = | 'ask' | 'compact' | 'interrupt' + | 'notice' | 'tool'; /** The comic panel a run of events is drawn as. */ @@ -120,6 +121,8 @@ export interface StoryEvent { description?: string; /** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */ ships?: 'pr' | 'push'; + /** Who a prompt came from (a prompt added mid-turn can come from someone other than the turn's starter). */ + origin?: StoryTurnOrigin; output?: string; outputTruncated?: boolean; edit?: StoryEdit; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index afc93d8b..5ebc5d07 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -735,7 +735,15 @@ const StoryArt = (() => { }, draw(ctx, W, H, f, p, hit) { const ax = Math.max(W * 0.78, W - 104); - hit('prompt', ax - 56, 110, 112, 140, p.page?.origin === 'task' ? 'Read the report' : 'Read the whole prompt'); + const from = p.page?.events[p.panel.events[0]]?.origin; + hit( + 'prompt', + ax - 56, + 110, + 112, + 140, + from === 'task' ? 'Read the report' : from === 'peer' ? 'Read the message' : 'Read the whole prompt' + ); const a = agent(ctx, ax, 244, { frame: f, mood: 'listen', @@ -2069,7 +2077,9 @@ Object.assign(CodemanApp.prototype, { rt.balloon.append(balloonSpan); } balloonSpan.textContent = balloonText || ''; - const notice = p.kind === 'mission' && index === 0 && page.origin && page.origin !== 'human'; + // A prompt somebody else sent (a background job, another session) is a notice, not your speech. + const origin = page.events[p.events[0]]?.origin ?? (index === 0 ? page.origin : 'human'); + const notice = p.kind === 'mission' && origin && origin !== 'human'; rt.balloon.className = `story-balloon ${ notice ? 'story-balloon--note' @@ -2646,9 +2656,16 @@ Object.assign(CodemanApp.prototype, { source.view === 'diff' && items.length && !items.some((x) => x.ev.edit || x.ev.pending) ? 'output' : source.view; if (view === 'prompt') { const prompts = items.filter((x) => x.ev.intent === 'prompt'); - const task = page.origin === 'task' && prompts[0]?.ev.id === page.events[0]?.id; - kind = task ? 'Report' : 'Mission'; - title = task ? 'A background job reports back' : prompts.length > 1 ? 'What you asked' : 'Your prompt'; + const from = prompts[0]?.ev.origin ?? 'human'; + kind = from === 'task' ? 'Report' : from === 'peer' ? 'Message' : 'Mission'; + title = + from === 'task' + ? 'A background job reports back' + : from === 'peer' + ? 'A message from another session' + : prompts.length > 1 + ? 'What you asked' + : 'Your prompt'; for (const { ev } of prompts) { out.push(el('pre', 'story-pre story-prompt', ev.label)); if (ev.output !== undefined) { diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 5e22d71b..483d3d03 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -358,6 +358,140 @@ describe('buildStoryTurns', () => { expect(second.events[0].output).toBe('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, 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 = [ + '', + 'a1', + 'completed', + 'Agent "Docs" finished', + 'README written.', + '', + ].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:', + '', + 'README written with examples.', + '', + '', + '{"type":"idle_notification","from":"readme-writer","idleReason":"available","result":"I wrote README.md."}', + '', + '', + '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 message from another session names its sender; pasted text loses its wrapper', () => { + const [peer] = buildStoryTurns( + entriesOf([ + prompt( + 'Rebase first, please.', + { + turnOrigin: undefined, + } + ), + ]) + ); + expect(peer).toMatchObject({ origin: 'peer', prompt: 'planner-2: Rebase first, please.' }); + const [pasted] = buildStoryTurns( + entriesOf([prompt('\nDo these steps.\n')]) + ); + expect(pasted.prompt).toBe('Do these steps.'); + }); + it('an interrupt ends the turn', () => { const turns = buildStoryTurns( entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')]) @@ -433,6 +567,15 @@ describe('buildStoryPage', () => { 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'),