mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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 <teammate-message> reports (text or a JSON idle notification) join it by name, and the turn they start reads "readme-writer: README done". - A <cross-session-message> names its sender; pasted text loses Claude Code's <pasted_content> 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
03cb3a5961
commit
848566828f
5 files changed
+343
-26
No files matched your search
@@ -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 `<task-notification>` 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 `<task-notification>` 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 `<teammate-message>` 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
|
||||
|
||||
|
||||
+174
-20
@@ -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 `<teammate-message>`: 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<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
@@ -465,11 +474,61 @@ const SYSTEM_REMINDER_RE = /<system-reminder>[\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: `<teammate-message teammate_id="x" summary="...">body</teammate-message>`,
|
||||
* 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(/<teammate-message\b([^>]*)>([\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 (/^<teammate-message\b/.test(text)) {
|
||||
const teammates = readTeammateMessages(text);
|
||||
const first = teammates.find((t) => 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 = /^<cross-session-message\b([^>]*)>([\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>([^<]*)<\/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<string, unknown>, 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<ReturnType<typeof readPrompt>>,
|
||||
id: string,
|
||||
at: string,
|
||||
origin: StoryTurnOrigin,
|
||||
queued: boolean
|
||||
): Extract<StoryEntry, { k: 'prompt' }> {
|
||||
const reduced: Extract<StoryEntry, { k: 'prompt' }> = {
|
||||
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<string, unknown>, 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<string, StoryEvent>();
|
||||
/** Helper calls by tool id, across turns: a background helper reports back in a later turn. */
|
||||
const helpers = new Map<string, StoryEvent>();
|
||||
/** The same helpers by name: a teammate reports back by name, not by call. */
|
||||
const helpersByName = new Map<string, StoryEvent>();
|
||||
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<StoryIntent>(['prompt', 'say', 'interrupt', 'compact']);
|
||||
const NOT_TOOL_CALLS = new Set<StoryIntent>(['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<StoryIntent, StoryPanelKind | null> = {
|
||||
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<StoryPanelKind>([
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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 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('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'),
|
||||
|
||||
Reference in new issue
Block a user