From 88040c997d8d2a67a1f87eac6491cdf966574104 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 15:23:19 +0200 Subject: [PATCH] fix(story-mode): no celebration when the turn is not a clean finish From review round 8: "Mission complete!" with confetti and cheering helpers sat over balloons saying the page will not work as opened, a helper's tests have not run, or a problem is left. The final panel is now calm (no confetti, a neutral robot) whenever work was still out at the turn's end, nothing worked, or Claude's last words name an open problem (`page.openNote`: a key point about something wrong, not done, not checked or not fixed); the last of these reads "Done, with a catch". Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/story-mode.ts | 19 +++++++++++++++---- src/types/story.ts | 2 ++ src/web/public/story-mode.js | 13 +++++++++++-- .../background-helpers-reports.expected.json | 9 +++++++++ ...ash-script-background-helper.expected.json | 3 +++ .../compact-then-plan-mode.expected.json | 3 +++ .../errors-push-append.expected.json | 4 ++++ .../failing-test-on-purpose.expected.json | 2 ++ .../long-turn-prompt-added.expected.json | 2 ++ .../python-cli-fix-loop.expected.json | 1 + .../question-tests-first-push.expected.json | 1 + .../teammates-then-interrupt.expected.json | 5 +++++ .../text-plan-dry-run-helper.expected.json | 2 ++ .../web-page-node-test.expected.json | 1 + test/story-corpus.test.ts | 3 +++ test/story-mode-ui.test.ts | 15 +++++++++++++++ 17 files changed, 80 insertions(+), 7 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index eb53cbb4..244d3277 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1074,7 +1074,7 @@ A turn is one prompt and everything Claude did for it. `origin` says who sent th ### Background work -A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `` (matched by tool call) or a `` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish. +A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `` (matched by tool call) or a `` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish. `page.openNote` says Claude's last words name an open problem or something not done or checked (one of its `keyPoints`), so that panel is not a celebration either. A Bash call sent to the background (its result is only `Command running in background with ID: …`) has `running: true` and no `ok`: it started, nothing more is known, and its `output` says so instead of carrying the launch note meant for Claude. Its `` settles `ok` from the exit code in the summary (the `output` then names the exit code, and `background: true` stays, so the page says the run happened in the background), and reading the job's output file later is a test run when that output reports test counts. diff --git a/src/story-mode.ts b/src/story-mode.ts index 3333e684..d4daa438 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -275,9 +275,14 @@ export function balloonText(text: string): string { * expected, a correction of an earlier warning. WEAK: a bare negation ("I didn't push"), kept only when * nothing strong is there. */ +/** The open-problem part of a key point: something wrong, not done, not checked, or still out. */ +const OPEN_PROBLEM = new RegExp( + String.raw`\b(problem|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|not yet|haven't|have not|never|unfixed|left (open|unfixed|out)|still (fails?|failing|broken|missing|open|running|working)|must be (served|run)|(won't|doesn't|does not|will not) (work|fix)|(isn't|is not|not) fixed|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened))\b`, + 'i' +); const KEY_POINT_STRONG = new RegExp( [ - String.raw`\b(problem|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|not yet|haven't|have not|never|unfixed|left (open|unfixed|out)|still (fails?|failing|broken|missing|open|running|working)|must be (served|run)|(won't|doesn't|does not|will not) work|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened))\b`, + OPEN_PROBLEM.source, String.raw`\b(as you (asked|expected|said|wanted|suggested)|you asked|your (--?[\w-]+|request|option|flag|change))\b`, String.raw`\b(out of date|looked too early|turned out|was wrong)\b`, ].join('|'), @@ -1991,7 +1996,7 @@ const REPORT_TALK: Partial> = { }; const ANNOUNCES = /\b(I'll|I will|let me|let's|I'm going to|next,? I|now I)\b/i; -function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string } { +function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string; openNote?: boolean } { const panels: DraftPanel[] = []; let pendingSay: string | undefined; let lastSaid: StoryEvent | undefined; @@ -2037,7 +2042,12 @@ function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay? pendingSay = undefined; }); // The closing words keep a caveat that follows them ("Nothing is committed: this is no git repo."). - return { panels, trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay }; + return { + panels, + trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay, + // Claude's last words name an open problem: the final panel is not a celebration. + openNote: !!lastSaid?.keyPoints?.some((k) => OPEN_PROBLEM.test(k)), + }; } /** Fix → check → fix → check (4 panels or more) is one "loop" panel: try, test, repeat. */ @@ -2611,7 +2621,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { if (todos) e.todos = todos; } - const { panels: grouped, trailingSay } = groupPanels(events); + const { panels: grouped, trailingSay, openNote } = groupPanels(events); const room = STORY_LIMITS.panelsPerPage - (turn.ended ? 1 : 0); const drafts = capPanels(collapseLoops(grouped), room); const panels = drafts.map((d, i) => decorate(d, events, turn, i)); @@ -2628,6 +2638,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { // The advisor is a second opinion, not a helper that does work. helpers: events.filter((e) => e.helper && e.tool !== 'Advisor' && e.intent !== 'prompt').length, helpersRunning: events.filter((e) => e.helper?.running).length, + ...(openNote && turn.ended && !turn.interrupted ? { openNote: true } : {}), ...(events.some((e) => e.finishedLater && e.intent !== 'prompt') ? { finishedLater: events.filter((e) => e.finishedLater && e.intent !== 'prompt').length } : {}), diff --git a/src/types/story.ts b/src/types/story.ts index 35a84476..7bc0e303 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -210,6 +210,8 @@ export interface StoryPage extends StoryTurnSummary { helpersRunning: number; /** Helpers and background commands of this turn that finished only after it ended. */ finishedLater?: number; + /** Claude's last words name an open problem or something not done or checked: no clean finish. */ + openNote?: boolean; /** Every tool call of the turn failed (a blocked command, nothing that worked): no celebration. */ noneWorked?: boolean; } diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index d94da8ff..1d66ff3a 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1436,7 +1436,14 @@ const StoryArt = (() => { } return; } - if (p.page.noneWorked) { + // Not a clean finish (nothing worked, work still out, or Claude named an open problem): no party. + const calm = + p.page.noneWorked || + p.page.helpersRunning || + p.page.finishedLater || + p.page.openNote || + p.page.events.some((e) => e.running && !e.helper); + if (calm) { agent(ctx, ax, 250, { frame: f, scale: sc, mood: 'neutral', l: [-34, -48], r: [34, -48] }); return; } @@ -2484,7 +2491,9 @@ Object.assign(CodemanApp.prototype, { : page.helpersRunning || page.finishedLater || page.events.some((e) => e.running && !e.helper) ? // Work was still out when the turn ended (and maybe still is): not a clean finish. 'Done for now' - : 'Mission complete!'; + : page.openNote + ? 'Done, with a catch' + : 'Mission complete!'; const chips = rt.splash.querySelector('.story-chips'); const items = []; if (page.durationMs !== undefined) items.push({ text: this._storyDuration(page.durationMs) }); diff --git a/test/fixtures/story-corpus/background-helpers-reports.expected.json b/test/fixtures/story-corpus/background-helpers-reports.expected.json index 10ddad2f..0b605342 100644 --- a/test/fixtures/story-corpus/background-helpers-reports.expected.json +++ b/test/fixtures/story-corpus/background-helpers-reports.expected.json @@ -12,6 +12,7 @@ "helpers": 2, "helpersRunning": 0, "finishedLater": 2, + "openNote": true, "panels": [ { "kind": "mission", @@ -81,6 +82,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -127,6 +129,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -176,6 +179,7 @@ "helpers": 1, "helpersRunning": 0, "finishedLater": 2, + "openNote": true, "panels": [ { "kind": "mission", @@ -242,6 +246,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -291,6 +296,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -337,6 +343,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -396,6 +403,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -432,6 +440,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", 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 3e5b835f..004a0c7f 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -12,6 +12,7 @@ "helpers": 1, "helpersRunning": 0, "finishedLater": 1, + "openNote": false, "panels": [ { "kind": "mission", @@ -108,6 +109,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -164,6 +166,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json index 6748142e..6779d113 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -101,6 +102,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -147,6 +149,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index 87913dac..64b2e55e 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -155,6 +156,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -227,6 +229,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -306,6 +309,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json index 8afe5fa9..bf6df5ab 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -104,6 +105,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json index 89ef9a42..fdfc1aad 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -160,6 +161,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json index e79330e8..f7e38985 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/question-tests-first-push.expected.json b/test/fixtures/story-corpus/question-tests-first-push.expected.json index 2db19109..3cc6ecf0 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": true, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index 7e674072..0dc92ff3 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -12,6 +12,7 @@ "helpers": 2, "helpersRunning": 0, "finishedLater": 2, + "openNote": false, "panels": [ { "kind": "mission", @@ -111,6 +112,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": true, "panels": [ { "kind": "mission", @@ -167,6 +169,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -226,6 +229,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", @@ -318,6 +322,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json index ee5edd81..1226a328 100644 --- a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json +++ b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json @@ -12,6 +12,7 @@ "helpers": 1, "helpersRunning": 0, "finishedLater": 1, + "openNote": true, "panels": [ { "kind": "mission", @@ -121,6 +122,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": true, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/web-page-node-test.expected.json b/test/fixtures/story-corpus/web-page-node-test.expected.json index 27bd343f..6a2fb9bf 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -12,6 +12,7 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, + "openNote": true, "panels": [ { "kind": "mission", diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index f8cf4138..5fd1e624 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -40,6 +40,7 @@ function pagesOf(name: string) { helpers: p.helpers, helpersRunning: p.helpersRunning, finishedLater: p.finishedLater ?? 0, + openNote: p.openNote ?? false, panels: p.panels.map((x) => ({ kind: x.kind, ok: x.ok ?? null, @@ -159,6 +160,8 @@ describe('Story Mode on real sessions', () => { const last = (name: string, turn: number) => pagesOf(name)[turn].panels.at(-1)!; // An open problem, said further into the final message than its first sentence. expect(last('web-page-node-test', 0).balloon).toMatch(/won't work if you double-click index\.html/); + // ...and the turn is not drawn as a clean finish. + expect(pagesOf('web-page-node-test')[0].openNote).toBe(true); expect(last('text-plan-dry-run-helper', 1).balloon).toMatch(/doesn't fix it/); // What you expected, and what you asked for mid-turn. expect(last('errors-push-append', 2).balloon).toMatch(/As you expected, git describe --tags then failed/); diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 2d884cd2..c20df25b 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -851,6 +851,21 @@ describe('story-mode.js overlay', () => { expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['+more']); }); + it('a turn whose last words name an open problem ends "Done, with a catch", not "Mission complete!"', async () => { + const words = [ + 'I built the pomodoro timer and its tests, and all of them pass.', + 'It keeps the time in memory and redraws the clock every second while it runs. '.repeat(3), + "Opening the page: it won't work if you double-click index.html.", + ].join(' '); + boot(storyOf([prompt('build it'), ...edit('e1', '/work/demo-shop/t.js', ['+x']), said(words), end(1000)])); + app.openStoryMode(SID); + await settle(); + expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch'); + expect($('.story-panel[data-kind="complete"] .story-balloon-text')!.textContent).toMatch( + /won't work if you double-click/ + ); + }); + it('the final panel counts the commits of the whole turn, and says when the work was uploaded', async () => { boot( storyOf([