From d7a6644c2f9e945c8d49d435db1b6b0a889fb50a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 10:43:04 +0200 Subject: [PATCH] fix(story-mode): one verdict per call, failed saves, and loops that end at a check From review round 2 (a question, failing tests first, and a push): - A run that reported failed tests is now failed on the event itself, so its drawer and wire tags agree with the panel (the TDD red run was a red panel with a green "ok" tag; `; echo "exit=$?"` had hidden the exit code). - A save whose command failed says so ("Claude tries to send the work up, and the command fails."), stamps ERROR instead of SAVED, and counts the error in the Director's cut. A save still running stamps nothing. - A loop ends at its last check: README, .gitignore and the like written after the tests passed are their own fix panel instead of disappearing into the loop's diff. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 30 +++++++++++++++++++++++------- src/types/story.ts | 5 ++++- src/web/public/story-mode.js | 19 ++++++++++++++----- test/story-mode.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index b08a56b3..5a896301 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -1008,7 +1008,11 @@ function applyResult(ev: StoryEvent, r: Extract): v ev.edit = r.edit; ev.file = r.edit.file; } - if (r.tests) ev.tests = r.tests; + if (r.tests) { + ev.tests = r.tests; + // A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0). + if (r.tests.failed > 0) ev.ok = false; + } if (r.helper && ev.helper) { const helper: StoryHelper = { ...ev.helper }; if (r.helper.background && !r.isError) { @@ -1330,6 +1334,8 @@ function collapseLoops(panels: DraftPanel[]): DraftPanel[] { ) { j++; } + // A loop ends at a check: writes after the last check (docs, config) are their own panel. + while (j > i && panels[j - 1].kind === 'fix') j--; const run = panels.slice(i, j); if (run.length >= 4) { out.push({ @@ -1573,12 +1579,22 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde break; case 'save': { panel.notes = notesOf(evs); - story = evs.some((e) => e.ships === 'pr') - ? 'Claude opens a pull request.' - : evs.some((e) => e.ships === 'push') - ? 'Claude saves the work and sends it up.' - : 'Claude saves the work.'; - director = evs.map(shortCommand).slice(0, 2).join(' · '); + const last = evs[evs.length - 1]; + if (last?.ok !== undefined) panel.ok = last.ok; + const ships = evs.some((e) => e.ships === 'pr') ? 'pr' : evs.some((e) => e.ships === 'push') ? 'push' : ''; + story = + panel.ok === false + ? ships === 'pr' + ? 'Claude tries to open a pull request, and the command fails.' + : ships === 'push' + ? 'Claude tries to send the work up, and the command fails.' + : 'Claude tries to save the work, and the command fails.' + : ships === 'pr' + ? 'Claude opens a pull request.' + : ships === 'push' + ? 'Claude saves the work and sends it up.' + : 'Claude saves the work.'; + director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}`; break; } case 'ask': { diff --git a/src/types/story.ts b/src/types/story.ts index 46b0a89d..5b92969c 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -109,7 +109,10 @@ export interface StoryEvent { tool: string; label: string; intent: StoryIntent; - /** False when the tool result was flagged as an error; absent while the call is still running. */ + /** + * False when the call failed: its result was flagged as an error, or it reported failed tests (an + * exit code a pipe hid). Absent while the call is still running. + */ ok?: boolean; pending?: boolean; /** The one file this event concerns (Read, Edit, Write), as the transcript gave it. */ diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 5ebc5d07..f132289d 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1180,25 +1180,34 @@ const StoryArt = (() => { ctx.fillStyle = C.line; ctx.fillRect(bx + 44, by + 18, 32, 8); hit('output', bx - 6, by - 14, 132, 104, 'See what was saved'); - const sp = spring(f - 28, { damping: 8, stiffness: 160 }); + // The stamp says how it went: SAVED, or ERROR when the command failed; none while it runs. + const failed = p.panel.ok === false; + const stamp = failed ? 'ERROR' : 'SAVED'; + const sp = p.panel.ok === undefined ? 0 : spring(f - 28, { damping: 8, stiffness: 160 }); if (sp > 0.01) { ctx.save(); ctx.translate(bx + 60, by + 56); - ctx.rotate(-0.18); + ctx.rotate(failed ? 0.14 : -0.18); ctx.scale(sp, sp); ctx.font = `20px ${DISPLAY}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const w = ctx.measureText('SAVED').width + 18; + const w = ctx.measureText(stamp).width + 18; rr(ctx, -w / 2, -15, w, 30, 4); ctx.strokeStyle = C.magenta; ctx.lineWidth = 3; ctx.stroke(); ctx.fillStyle = C.magenta; - ctx.fillText('SAVED', 0, 1); + ctx.fillText(stamp, 0, 1); ctx.restore(); } - agent(ctx, W - 50, 250, { frame: f, mood: 'happy', scale: 0.8, l: [-30, -52], r: [26, -128] }); + agent(ctx, W - 50, 250, { + frame: f, + mood: failed ? 'shock' : p.panel.ok === undefined ? 'focus' : 'happy', + scale: 0.8, + l: [-30, -52], + r: failed ? [30, -60] : [26, -128], + }); }, }, ask: { diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 483d3d03..03117f3b 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -607,6 +607,34 @@ describe('buildStoryPage', () => { expect(loop.tests).toEqual({ passed: 4, failed: 0 }); }); + it('a failed save says so; the failing run is marked failed on the event itself', () => { + const page = pageOf([ + prompt('ship'), + ...bash('b1', 'pytest -q; echo "exit=$?"', 'Run tests', '1 error in 0.22s\nexit=2'), + ...bash('b2', 'git push -u origin main && git -C remote.git log', 'Push', 'fatal: bad object', true), + end(1000), + ]); + expect(page.events[1]).toMatchObject({ ok: false, tests: { passed: 0, failed: 1 } }); + const save = page.panels.find((p) => p.kind === 'save')!; + expect(save.ok).toBe(false); + expect(save.caption.story).toBe('Claude tries to send the work up, and the command fails.'); + expect(save.caption.director).toContain('1 error'); + }); + + it('a loop ends at its last check; writes after it are a panel of their own', () => { + const page = pageOf([ + prompt('tdd'), + ...edit('e1', '/w/test_a.py', ['+def test_a(): ...']), + ...bash('b1', 'pytest -q', 'Run tests', '1 failed in 0.1s'), + ...edit('e2', '/w/a.py', ['+a = 1']), + ...bash('b2', 'pytest -q', 'Run tests', '1 passed in 0.1s'), + ...edit('e3', '/w/README.md', ['+# a']), + end(1000), + ]); + expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'loop', 'fix', 'complete']); + expect(page.panels[2].files).toEqual(['/w/README.md']); + }); + it('what Claude says about a check that just ran stays on that check', () => { const page = pageOf([ prompt('test then commit'),