mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
1 parent
ea104c8965
commit
db9eb5cea3
3 files changed
+77
-9
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 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
@@ -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
@@ -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]')])
|
||||
|
||||
Reference in new issue
Block a user