From bcfef8af5b4b1924fd2751bbbf20c6be5b9205db Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 18:05:48 +0200 Subject: [PATCH] fix(story-mode): a quoted report marks what it skips, a run that never loaded says so (review round 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the fact-checker's last items: - A helper's report quoted in a notice joined its first sentence to one from paragraphs later with nothing between: where text was left out, the balloon now says "…" (found in the report itself, so two sentences that follow each other are never marked). - "The first run fails on purpose, as you asked" for a run that never loaded a test (a collection error) now reads "At first the tests don't even load, as you asked; the second run passes.", as a test panel already says it. - A list intro without a count ("What failed and how I fixed it:") brings its items while they fit in 360 characters, not 260. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 37 +++++++++++++++---- .../question-tests-first-push.expected.json | 2 +- .../text-plan-dry-run-helper.expected.json | 2 +- test/story-mode.test.ts | 24 ++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index acd6daa0..86694dab 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -411,7 +411,7 @@ function keyPointsOf(text: string): string[] { )?.[1]; const lines = count ? (/^(three|3)$/i.test(count) ? 3 : 2) : 0; wide = lines > 0; - const room = lines ? 480 : 260; + const room = lines ? 480 : 360; for ( let k = i + 1; k < sentences.length && @@ -458,13 +458,30 @@ function keyPointsOf(text: string): string[] { * further in (an open problem, what was or was not done), so the final panel never reads as a clean * finish when Claude said otherwise. */ -export function closingText(text: string, keyPoints: string[] = []): string { +export function closingText(text: string, keyPoints: string[] = [], gap = ' '): string { const [one, two] = firstTwoSentences(text); // A very short first sentence ("Not done.") says too little alone. - const parts = [one]; - if (two && (CAVEAT.test(two) || OUTCOME.test(two) || one.length < 24) && two.length <= 200) parts.push(two); - for (const point of keyPoints) if (!parts.some((p) => p.includes(point) || point.includes(p))) parts.push(point); - return clip(parts.join(' '), parts.length > 1 ? 560 : 300); + const head = [one]; + if (two && (CAVEAT.test(two) || OUTCOME.test(two) || one.length < 24) && two.length <= 200) head.push(two); + const more: string[] = []; + for (const point of keyPoints) + if (![...head, ...more].some((p) => p.includes(point) || point.includes(p))) more.push(point); + // `gap` marks where the balloon skips ahead (" … " for a helper's report quoted in a notice): only + // where text was left out between two kept sentences, found in the message itself. + const flat = (t: string) => + t + .replace(/[`*_#>|-]/g, '') + .replace(/\s+/g, ' ') + .trim(); + const whole = gap === ' ' ? '' : flat(text.slice(0, 8000)); + let out = head.join(' '); + let end = whole ? whole.indexOf(flat(out).slice(0, 60)) + flat(out).length : -1; + for (const point of more) { + const at = whole ? whole.indexOf(flat(point).slice(0, 60)) : -1; + out += at >= 0 && end >= 0 && at - end <= 2 ? ` ${point}` : `${gap}${point}`; + end = at >= 0 ? at + flat(point).length : -1; + } + return clip(out, head.length + more.length > 1 ? 560 : 300); } /** The first sentence of a message, without markdown, for a speech balloon. */ @@ -2510,7 +2527,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde if (panel.balloon && report) { // A report that is only a path says which file it wrote: its name is enough. const pathOnly = /^\S+$/.test(report) && report.includes('/'); - const said = (pathOnly ? basename(report) : closingText(report, keyPointsOf(report))) + const said = (pathOnly ? basename(report) : closingText(report, keyPointsOf(report), ' … ')) // An absolute path is mostly room: its last two parts ("tests/test_extra.py") say which file. // Run on the clipped balloon (560 characters at most), so the scan stays short. .replace(/(? !!e.tests && !e.tests.passed && !e.tests.failed && !!e.tests.errors; // A failure you asked for (test first), or that Claude said it expected, is the plan working. const planned = passed(checks[0]) === false ? plannedFailure(turn, events) : false; // The check that failed while the tests passed, by name: "a formatting check", not "another check". @@ -2698,7 +2717,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde ? `Build and test, ${plural(n, 'round')}. The tests pass every time; ${otherCheck} fails ${fails === 1 ? 'once' : `${fails} times`}, then passes.` : panel.ok === true && planned === 'asked' ? checks.length === 2 - ? 'The first run fails on purpose, as you asked; the second passes.' + ? loadsNot(checks[0]) + ? "At first the tests don't even load, as you asked; the second run passes." + : 'The first run fails on purpose, as you asked; the second passes.' : `${plural(n, 'round')}: the first run fails on purpose, as you asked, then it passes.` : panel.ok === true && planned && checks.length === 2 ? 'Test first: the first run fails as planned, the second passes.' 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 7ac8b0b6..de05ea4c 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -54,7 +54,7 @@ { "kind": "loop", "ok": true, - "story": "The first run fails on purpose, as you asked; the second passes.", + "story": "At first the tests don't even load, as you asked; the second run passes.", "unaskedSave": false, "director": "2 checks · 4 edits · +150 · last: ok · after 1 second opinion", "balloon": "All 19 test cases pass (13 test functions), and ruff check and ruff format both come back clean.", 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 cce05009..6a35b674 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 @@ -142,7 +142,7 @@ "story": "A background job reports back.", "unaskedSave": false, "director": "Prompt · 6 words · task", - "balloon": "\"Write README for csvstat\" reports: I wrote README.md and committed it on its own on feat/csvstat. The README has every section you asked for and no badges.", + "balloon": "\"Write README for csvstat\" reports: I wrote README.md and committed it on its own on feat/csvstat. … The README has every section you asked for and no badges.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index e99b1841..28248e40 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -9,6 +9,7 @@ import { buildStoryPage, buildStoryTurns, classifyBashCommand, + closingText, createStoryReducer, exactDuration, firstSentence, @@ -1103,6 +1104,29 @@ describe('buildStoryPage', () => { expect(performance.now() - start).toBeLessThan(1500); }); + it('a quoted report marks only the text it skips with "…"', () => { + const report = [ + 'I wrote README.md and committed it.', + 'It covers usage and the exit codes.', + 'It reads the file and prints the counts for each column, one line per column. '.repeat(3), + 'One known issue: big numbers are shown rounded.', + ].join('\n\n'); + // The second sentence follows the first: no mark. The issue is three paragraphs on: marked. + expect( + closingText( + report, + ['It covers usage and the exit codes.', 'One known issue: big numbers are shown rounded.'], + ' … ' + ) + ).toBe( + 'I wrote README.md and committed it. It covers usage and the exit codes. … One known issue: big numbers are shown rounded.' + ); + // Claude's own closing words are never marked. + expect(closingText(report, ['One known issue: big numbers are shown rounded.'])).toBe( + 'I wrote README.md and committed it. One known issue: big numbers are shown rounded.' + ); + }); + it('a short ending keeps the sentence that leaves something open', () => { const page = pageOf([ prompt('run them'),