fix(story-mode): slash commands, and where a compaction belongs

A local slash command (`/compact`, `/model`) gets no reply from Claude and no
turn_duration, so its turn stayed "Claude is working" until the next prompt.
It now ends at the command's own output (<local-command-stdout>, kept for
the mission drawer) and reads "You run /compact.". `/compact` writes its
compaction marker before the command line itself; a marker that lands
between turns now belongs to the turn after it (it was drawn at the end of
the previous, finished turn), and one mid-turn stays where it is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 11:18:38 +02:00
1 parent ea104c8965
commit db9eb5cea3
3 files changed
+77 -9

No files matched your search

+1 -1
View File
@@ -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 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 Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). 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.
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 Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A slash command is a prompt event with tool `Command`; a local one (`/compact`, `/model`) ends its turn at its own output (`<local-command-stdout>`, kept as the event's `output`), and a compaction that lands between turns belongs to the turn after it. 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
+43 -7
View File
@@ -111,6 +111,8 @@ export type StoryEntry =
text: string;
origin: StoryTurnOrigin;
queued: boolean;
/** A slash command (`/compact`), not a prompt to Claude. */
slash?: 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. */
@@ -134,6 +136,8 @@ export type StoryEntry =
}
| { k: 'end'; at: string; durationMs?: number }
| { k: 'compact'; at: string }
/** What a local slash command printed (`<local-command-stdout>`): its whole answer. */
| { k: 'local'; at: string; text: string }
| { k: 'interrupt'; at: string };
/** What a `<task-notification>` says about the background call it answers. */
@@ -600,6 +604,8 @@ function readTeammateMessages(text: string): StoryTeammateReport[] {
function readPrompt(raw: string): {
text: string;
kind: 'prompt' | 'task' | 'peer' | 'interrupt';
/** A slash command (`/compact`, `/model`), from its `<command-name>`. */
slash?: boolean;
task?: StoryTaskReport;
teammates?: StoryTeammateReport[];
} | null {
@@ -629,7 +635,7 @@ function readPrompt(raw: string): {
const command = /<command-name>([^<]*)<\/command-name>/.exec(text);
if (command) {
const args = /<command-args>([^<]*)<\/command-args>/.exec(text)?.[1] ?? '';
return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt' };
return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt', slash: true };
}
if (/^<task-notification>/.test(text)) {
const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
@@ -887,6 +893,12 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
if (results.length) return out;
}
if (entry.isMeta === true || entry.turnCompanion === true) return out;
const local = /^\s*<local-command-(stdout|stderr)>([\s\S]*?)<\/local-command-\1>/.exec(blockText(content));
if (local) {
// eslint-disable-next-line no-control-regex
const text = local[2].replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim();
return [{ k: 'local', at, text: clip(text, 2000) }];
}
const prompt = readPrompt(blockText(content));
if (!prompt) return out;
if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }];
@@ -932,6 +944,7 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
origin,
queued,
};
if (prompt.slash) reduced.slash = true;
if (prompt.task) {
const t = prompt.task;
reduced.task = {
@@ -1181,6 +1194,8 @@ 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>();
/** A compaction between turns, held for the turn that comes next. */
let pendingCompact: StoryEvent | null = null;
/** The same helpers by name: a teammate reports back by name, not by call. */
const helpersByName = new Map<string, StoryEvent>();
let say = 0;
@@ -1244,7 +1259,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
id: e.id,
at: e.at,
actor: e.origin === 'human' ? 'you' : 'system',
tool: 'Prompt',
tool: e.slash ? 'Command' : 'Prompt',
label: e.text,
intent: 'prompt',
origin: e.origin,
@@ -1274,6 +1289,10 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
};
turns.push(cur);
byId.clear();
if (pendingCompact) {
cur.events.push(pendingCompact);
pendingCompact = null;
}
}
break;
}
@@ -1318,16 +1337,31 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
}
break;
}
case 'compact':
ensure(e.at).events.push({
case 'compact': {
const ev: StoryEvent = {
id: `compact-${say++}`,
at: e.at,
actor: 'system',
tool: 'Compact',
label: 'Context compacted',
intent: 'compact',
});
};
// Between turns (`/compact` writes its boundary before the command line itself), it belongs to
// the turn that comes next; mid-turn (an automatic compaction), to the running one.
const t = cur as StoryTurn | null;
if (t && !t.ended) t.events.push(ev);
else pendingCompact = ev;
break;
}
case 'local': {
// A local slash command answers itself: its turn ends with what it printed.
const t = cur as StoryTurn | null;
if (t && !t.ended && t.events[0]?.tool === 'Command' && countToolCalls(t.events) === 0) {
if (e.text) t.events[0].output = e.text;
closeTurn(t, e.at);
}
break;
}
case 'interrupt': {
const t = ensure(e.at);
t.events.push({
@@ -1623,8 +1657,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
system: 'Something automatic starts this turn.',
};
const origin = evs[0]?.origin ?? turn.origin;
story =
index === 0
const command = evs[0]?.tool === 'Command' ? evs[0].label.split(/\s/)[0] : '';
story = command
? `You run ${command}.`
: index === 0
? lines[turn.origin]
: origin === 'human'
? 'You add something while Claude works.'
+33 -1
View File
@@ -245,7 +245,8 @@ describe('createStoryReducer', () => {
prompt('<local-command-stdout>ok</local-command-stdout>'),
prompt('<bash-input>ls</bash-input>'),
]);
expect(entries).toEqual([]);
// 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', () => {
@@ -506,6 +507,37 @@ describe('buildStoryTurns', () => {
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('an interrupt ends the turn', () => {
const turns = buildStoryTurns(
entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])