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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 10:43:04 +02:00
1 parent 848566828f
commit d7a6644c2f
4 files changed
+69 -13

No files matched your search

+23 -7
View File
@@ -1008,7 +1008,11 @@ function applyResult(ev: StoryEvent, r: Extract<StoryEntry, { k: 'result' }>): 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': {
+4 -1
View File
@@ -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. */
+14 -5
View File
@@ -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: {
+28
View File
@@ -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'),