From 277cc8814826a188e58cd26c8268f59178397b2c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 16:23:11 +0200 Subject: [PATCH] fix(story-mode): a second opinion in front of helpers neither splits them nor counts as one Since second opinions ride on the next panel, an advisor call sorted to the front of a helper panel tripped the old rule that kept advisor panels apart, so "advisor, helper A, helper B" drew two helper panels, and the first counted the advisor as a helper ("Claude calls in 2 helpers" for one helper). The rule has no job left and is gone; a riding second opinion is never counted as a helper. One recorded page changes: "Claude sends 1 helper off to work in the background." Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 9 +++---- ...ash-script-background-helper.expected.json | 2 +- test/story-mode.test.ts | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index 90c9222f..f3d29e9a 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -2104,11 +2104,7 @@ function groupPanels(events: StoryEvent[]): { const kind = ev.todos && ev.intent === 'say' ? 'plan' : PANEL_FOR[ev.intent]; if (!kind) return; const last = panels[panels.length - 1]; - const advisorMix = - kind === 'backup' && - last?.kind === 'backup' && - (events[last.events[0]].tool === 'Advisor') !== (ev.tool === 'Advisor'); - if (last && last.kind === kind && MERGEABLE.has(kind) && !advisorMix) { + if (last && last.kind === kind && MERGEABLE.has(kind)) { last.events.push(idx); takeOpinions(last); if (pendingSay && !last.balloon) last.balloon = { who: 'claude', text: pendingSay }; @@ -2424,8 +2420,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde break; } case 'backup': { + // A second opinion riding on this panel is no helper of its own. const helpers = draft.events - .filter((i) => events[i].helper) + .filter((i) => events[i].helper && (!panel.secondOpinion || events[i].tool !== 'Advisor')) .map((i) => ({ name: (events[i].helper as StoryHelper).name, type: (events[i].helper as StoryHelper).type, diff --git a/test/fixtures/story-corpus/bash-script-background-helper.expected.json b/test/fixtures/story-corpus/bash-script-background-helper.expected.json index 4d536665..6794ca43 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -51,7 +51,7 @@ { "kind": "backup", "ok": null, - "story": "Claude calls in 2 helpers.", + "story": "Claude sends 1 helper off to work in the background.", "director": "Agent · general-purpose · 6 calls inside · after 1 second opinion", "balloon": null, "tests": null, diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index a8aa10b3..b4b94a0d 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1312,6 +1312,32 @@ describe('buildStoryPage', () => { ); }); + it('a second opinion in front of two helpers neither splits them nor counts as one', () => { + const advisor = { + type: 'assistant', + timestamp: '2026-10-18T09:41:20.000Z', + message: { + content: [ + { type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} }, + { type: 'advisor_tool_result', tool_use_id: 'srv1', content: { type: 'advisor_redacted_result' } }, + ], + }, + }; + const page = pageOf([ + prompt('go'), + advisor, + tool('a1', 'Agent', { description: 'A', subagent_type: 'general-purpose', prompt: 'x' }), + result('a1', 'done A'), + tool('a2', 'Agent', { description: 'B', subagent_type: 'general-purpose', prompt: 'y' }), + result('a2', 'done B'), + end(1000), + ]); + const backups = page.panels.filter((p) => p.kind === 'backup'); + expect(backups).toHaveLength(1); + expect(backups[0]).toMatchObject({ secondOpinion: 1, caption: { story: 'Claude calls in 2 helpers.' } }); + expect(backups[0].helpers?.map((h) => h.task)).toEqual(['A', 'B']); + }); + it('the advisor (a server tool) is a helper giving a second opinion', () => { const page = pageOf([ prompt('think hard'),