diff --git a/docs/api-reference.md b/docs/api-reference.md index 3dee5372..8c971dab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1082,7 +1082,7 @@ A Bash call sent to the background (its result is only `Command running in backg A `page` carries the turn's summary fields plus: -- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, that a python or node script fed by a here-document opens for writing, or that Claude Code says it changed; the last two have no lines, since what changed is not in the command; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running`, `background` and `finishedLater` (see Background work), `keyPoints` (for a sentence Claude wrote: up to two sentences from further into a long message that a reader must not lose, such as an open problem, something not yet done or checked, or a word on what you asked for; read on the whole message, past the `label`'s clip, and used by the final panel's balloon), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript, so it is never a panel of its own: the panel of the step after it (or the last one, when nothing follows) holds its events and counts them in `secondOpinion`. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`). +- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, that a python or node script fed by a here-document opens for writing, or that Claude Code says it changed; the last two have no lines, since what changed is not in the command; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running`, `background` and `finishedLater` (see Background work), `keyPoints` (for a sentence Claude wrote: up to two sentences from further into a long message that a reader must not lose, such as an open problem, something not yet done or checked, or a word on what you asked for; read on the whole message, past the `label`'s clip, and used by the final panel's balloon), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript, so it is never a panel of its own: the panel of the step after it (or the last one, when nothing follows) holds its events and counts them in `secondOpinion` (an advisor call that returned an error is drawn as a second opinion that got no answer). In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`). - `panels`: at most 12, each `{ kind, events, caption: { story, director }, balloon?, files?, notes?, helpers?, todos?, tests?, ok?, rounds?, roundsOk?, question?, live? }`, with `events` as indices into `events`. A run panel's `printed` holds the first lines its last command printed (its last lines when it failed), for the terminal card: the card is drawn on the canvas and goes into a saved image, so lines that look like secrets are left out and addresses and long token-like strings are masked. `balloon.who` is `you`, `claude` or `note` (words that start a turn but are not yours: a background job's report, another session's message); a helper's report keeps its own words there (`"" reports: …`, its first sentence and key points, absolute paths shortened to their last two parts). A loop's `roundsOk` lists every test run in order, `true` where it passed and `null` for one still running, so a failure inside a round that passed in the end still shows, and a live loop shows the runs that finished. Kinds: `mission`, `investigate`, `research`, `plan`, `backup`, `fix`, `check`, `loop` (fix and check rounds folded), `run`, `save`, `ask`, `compact`, `montage` (quieter panels folded to fit) and `complete`. - `filesChanged`, `added`, `removed` (summed per edit), `helpers` (the turn's own helper calls; a report turn's prompt carries the helper it answers, which is not counted), `helpersRunning`, `noneWorked` (every tool call of the turn failed, so the last panel is no celebration; a command whose last check only found nothing, exit code 1 after its answer, does not count as failed), `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs, hunks and helper texts were dropped to keep the page small: a page carries at most 120,000 characters of them, newest first; an edit that lost its lines keeps its counts and an empty `hunks`). diff --git a/src/story-mode.ts b/src/story-mode.ts index 0fd52684..c3d150fa 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -263,6 +263,10 @@ const REPORTS_PROBLEM = /\b(left|stray|broke|broken|fail(s|ed)?|error|missing|wr const CAVEAT = /^(but|however|note|nothing|no\b|none|one (caveat|gap|thing)|heads up|caveat|warning|i (didn't|did not|couldn't|could not|haven't|have not|wasn't|left)|it (isn't|is not|wasn't|was not)|this (isn't|is not)|the (only|one) )/i; +/** A sentence that says how it ended: the tests, the save. Kept in Claude's last words after the first. */ +const OUTCOME = + /\b(all \d+ (tests? )?(now )?pass|tests? (now )?pass|(is|are|was) (now )?committed|committed (it|them|the)|pushed|nothing is (lost|committed))\b/i; + /** What a speech balloon says: the first sentence, and the next when the first reports a problem. */ export function balloonText(text: string): string { const [one, two] = firstTwoSentences(text); @@ -295,7 +299,7 @@ const KEY_KINDS: Array<[StoryKeyKind, RegExp]> = [ ], [ 'offer', - /\b(want me to|should i|shall i|do you want|if you want|whenever you want|let me know|say if you want|i can (add|fix|change|do|update|make|run|commit))\b/i, + /\b(want me to|should i|shall i|do you want|if you want|whenever you want|let me know|say if you want|i can (add|fix|change|do|update|make|run|commit)|you (may|might) want to (change|revisit|undo|review)|decisions? (you|for you)|worth a second look)\b/i, ], [ 'howto', @@ -307,7 +311,7 @@ const KEY_KINDS: Array<[StoryKeyKind, RegExp]> = [ /(? (kind !== 'problem' || openProblem(s)) && ((kind !== 'problem' && kind !== 'pending') || !HISTORY.test(s)) )?.[0]; +/** A sentence whose "it" or "that" is further back: "would fix it", "That's the cause". */ +const POINTS_BACK = /^(that|this|it)\b|\b(fix|solve|change|handle|avoid|prevent|add) (it|that|this|them)\b/i; /** A sentence that only points elsewhere ("described below"): nothing a balloon can show. */ const POINTS_ELSEWHERE = /\b(described|listed|shown|details?) below\b|\bsee below\b|\bas follows\b/i; /** The fix that follows a problem sentence: then the problem is not open. */ @@ -371,13 +377,18 @@ function keyPointsOf(text: string): string[] { const plain = plainMarkdown(text.slice(0, 8000), spans); // Every sentence in order, with the line it came from (list items are lines). const sentences: string[] = []; + const lineOf: number[] = []; + let lineNo = 0; for (const line of plain.split('\n')) { const trimmed = line.trim(); + if (!trimmed) continue; + lineNo++; if (trimmed.startsWith('|') || (trimmed.match(/\|/g)?.length ?? 0) >= 2) continue; // a table row let rest = trimmed.replace(/^[-*•]\s+/, '').replace(/\s+/g, ' '); while (rest) { const m = SENTENCE_END.exec(rest); sentences.push((m ? m[1] : rest).trim()); + lineOf.push(lineNo); rest = m ? rest.slice(m[1].length).trim() : ''; } } @@ -390,20 +401,46 @@ function keyPointsOf(text: string): string[] { // Judged on its own words (a list intro too: "Known quirks:", "Two places where I didn't…:"). const kind = sentenceKind(sentence); if (!kind && (weak || !KEY_WEAK.test(sentence))) continue; - // A list intro brings its first items, while short, never one that only points elsewhere. - if (/:$/.test(sentence)) - for (let k = i + 1; k <= i + 2 && k < sentences.length && sentence.length + sentences[k].length < 260; k++) + // A list intro brings its first items, while short, never one that only points elsewhere. One that + // announces how many ("Two places where I didn't follow your wording:") brings that many lines. + let wide = false; + if (/:$/.test(sentence)) { + const count = + /\b(two|three|2|3)\s+(places|things|points|changes|issues|differences|deviations|items|notes|decisions|quirks|caveats)\b/i.exec( + sentence + )?.[1]; + const lines = count ? (/^(three|3)$/i.test(count) ? 3 : 2) : 0; + wide = lines > 0; + const room = lines ? 480 : 260; + for ( + let k = i + 1; + k < sentences.length && + (lines ? lineOf[k] <= lineOf[i] + lines : k <= i + 2) && + sentence.length + sentences[k].length < room; + k++ + ) if (!POINTS_ELSEWHERE.test(sentences[k])) sentence = `${sentence} ${sentences[k]}`; + } // "That's out of date" needs what "that" was, and so does "Want me to add that?". // ("It doesn't mention…" is a sentence of its own: only "that"/"this" point back.) const refersBack = /^(that|this)\b/i.test(sentence) || (kind === 'offer' && sentence.length < 80 && /\b(that|this|it|them)\b/i.test(sentence)); - const before = refersBack && i > 1 ? `${sentences[i - 1]} ` : ''; + let before = refersBack && i > 1 ? `${sentences[i - 1]} ` : ''; + // "…would fix it for up to 10 files. Want me to add that?": what "it" is sits further back, at the + // start of that paragraph ("Suffix swap: the two files … would trade their _1 suffix."). + if (before && kind === 'offer' && i > 2 && POINTS_BACK.test(sentences[i - 1])) { + let start = i - 1; + while (start > 1 && lineOf[start - 1] === lineOf[i - 1]) start--; + if (start < i - 1) before = `${sentences[start]}${start < i - 2 ? ' …' : ''} ${before}`; + } // A problem reported with its fix right after it: both, so it never reads as still open. const fix = kind === 'problem' && sentences[i + 1] && FIX_FOLLOWS.test(sentences[i + 1]) ? ` ${sentences[i + 1]}` : ''; - const shown = clip(restoreSpans(before + sentence + fix, spans), before || fix ? 360 : 300); + const shown = clip( + restoreSpans(before + sentence + fix, spans), + before.includes(' …') || wide ? 480 : before || fix ? 360 : 300 + ); if (kind) candidates.push({ at: i, text: shown, kind }); else weak = { at: i, text: shown }; } @@ -425,9 +462,9 @@ export function closingText(text: string, keyPoints: string[] = []): 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) || one.length < 24) && two.length <= 200) parts.push(two); + 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 > 2 ? 560 : 300); + return clip(parts.join(' '), parts.length > 1 ? 560 : 300); } /** The first sentence of a message, without markdown, for a speech balloon. */ @@ -2634,6 +2671,13 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde tests.errors ? `, ${plural(tests.errors, 'error')}` : '' }`; if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`; + // Tests run in a fresh copy (`git clone remote.git "$C" && (cd "$C" && pytest)`): the copy is what passed. + const cloned = /\bgit clone\b(?:\s+-{1,2}[\w-]+)*\s+["']?([^\s"';&|]+)/.exec(lastCheck?.label ?? '')?.[1]; + if (cloned && /^The (tests|checks) (pass|fail)\w*/.test(story)) + story = story.replace( + /^The (tests|checks) (pass|fail)(\w*)/, + `The $1 $2$3 in a fresh copy made from ${basename(cloned.replace(/\/+$/, ''))}` + ); const written = unique(edits.map((d) => d.file)); if (written.length) story = `Claude writes ${fileWords(written)}; ${story[0].toLowerCase()}${story.slice(1)}`; const saved = checks.reduce((n, e) => n + (e.commits ?? 0), 0); @@ -2704,12 +2748,31 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde // Its conventional prefix (`feat:`, `chore(deps):`) is for git, not for the reader. const plainSubject = subject?.replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, ''); const said = plainSubject ? `: "${clip(plainSubject, 60).replace(/[.]$/, '')}"` : ''; + // What came after the save in that command: only looking ("git log", "git status") makes it a check. + const ship = (last?.label ?? '').search(ships === 'push' ? /\bgit push\b/ : /\bgit commit\b/); + const after = + ship < 0 + ? [] + : (last?.label ?? '') + .slice(ship) + .split(/&&|\|\||;|\n/) + .slice(1) + .map((s) => s.trim()) + .filter(Boolean); + const looksOnly = + after.length > 0 && + after.every((s) => + /^(echo|printf|ls|cat|head|tail|wc|git (-C \S+ )?(log|status|remote|ls-remote|show|diff|branch|rev-parse|describe))\b/.test( + s + ) + ); + const stepAfter = looksOnly ? 'a check after that fails' : 'a step after that fails'; story = savedAnyway ? uploadOnly - ? `Claude ${uploads('the work')}, but a step after that fails.` + ? `Claude ${uploads('the work')}, but ${stepAfter}.` : ships === 'push' - ? `Claude saves the work${inCommits} and ${uploads('it')}, but a step after that fails.` - : `Claude saves the work${inCommits}, but a step after that fails.` + ? `Claude saves the work${inCommits} and ${uploads('it')}, but ${stepAfter}.` + : `Claude saves the work${inCommits}, but ${stepAfter}.` : panel.ok === false ? ships === 'pr' ? 'Claude tries to open a pull request, and the command fails.' @@ -2783,7 +2846,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde // Asked before the step, or (nothing following) after the last one. const lastWork = Math.max(...draft.events.filter((i) => events[i].tool !== 'Advisor')); const trailing = draft.events.some((i) => events[i].tool === 'Advisor' && i > lastWork); - director += ` · ${trailing ? 'then' : 'after'} ${plural(panel.secondOpinion, 'second opinion')}`; + const unanswered = draft.events.every((i) => events[i].tool !== 'Advisor' || events[i].ok === false); + director += ` · ${trailing ? 'then' : 'after'} ${plural(panel.secondOpinion, 'second opinion')}${unanswered ? ' (no answer)' : ''}`; } panel.caption = { story, director }; return panel; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 73f09d31..ce5b4d1c 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2492,11 +2492,16 @@ Object.assign(CodemanApp.prototype, { if (rt.capNote) { // Asked before this step, or after it when nothing followed. const work = p.events.filter((i) => page.events[i]?.tool !== 'Advisor'); - const trailing = p.events.some((i) => page.events[i]?.tool === 'Advisor' && i > Math.max(...work)); + const asked = p.events.filter((i) => page.events[i]?.tool === 'Advisor'); + const trailing = asked.some((i) => i > Math.max(...work)); + // A call that came back with an error brought no opinion: say so, never that one was given. + const unanswered = asked.length > 0 && asked.every((i) => page.events[i].ok === false); rt.capNote.hidden = !p.secondOpinion; - rt.capNote.textContent = p.secondOpinion - ? `${trailing ? 'then asks' : 'after asking'} a stronger AI model for a second opinion` - : ''; + rt.capNote.textContent = !p.secondOpinion + ? '' + : unanswered + ? `${trailing ? 'then tries to ask' : 'after trying to ask'} a stronger AI model for a second opinion (no answer came)` + : `${trailing ? 'then asks' : 'after asking'} a stronger AI model for a second opinion`; } rt.el.classList.toggle('is-live', !!p.live); // Named by its place only: the caption inside is read as the panel's content, once. diff --git a/test/fixtures/story-corpus/background-helpers-reports.expected.json b/test/fixtures/story-corpus/background-helpers-reports.expected.json index d5989cac..f0864094 100644 --- a/test/fixtures/story-corpus/background-helpers-reports.expected.json +++ b/test/fixtures/story-corpus/background-helpers-reports.expected.json @@ -172,7 +172,7 @@ "ok": null, "story": "Done in 11 seconds.", "director": "turn_duration · 11 s · 1 tool call", - "balloon": "Both helpers have finished.", + "balloon": "Both helpers have finished. Against the real wordcount.py, all 22 tests pass: my 4 in tests/test_basic.py and helper B's 18 in tests/test_extra.py.", "tests": null, "roundsOk": null, "printed": null, 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 148fc453..e7c5910f 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -52,7 +52,7 @@ "kind": "backup", "ok": null, "story": "Claude sends 1 helper off to work in the background.", - "director": "Agent · general-purpose · 6 calls inside · after 1 second opinion", + "director": "Agent · general-purpose · 6 calls inside · after 1 second opinion (no answer)", "balloon": null, "tests": null, "roundsOk": null, @@ -244,7 +244,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 14 s · 9 tool calls · 2 files changed", - "balloon": "I added a --prefix option to rename_by_date.sh. Making the script sort file names the plain byte-by-byte way would fix it for up to 10 files with the same time. Want me to add that?", + "balloon": "I added a --prefix option to rename_by_date.sh. Suffix swap: the two files with the same time (2024-05-01_093000*) would trade their _1 suffix. … Making the script sort file names the plain byte-by-byte way would fix it for up to 10 files with the same time. Want me to add that?", "tests": null, "roundsOk": null, "printed": null, 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 0850d5ca..2a519fcc 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -238,7 +238,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 7 s · 10 tool calls · 2 files changed", - "balloon": "I added wrap() as planned, with 3 new tests.", + "balloon": "I added wrap() as planned, with 3 new tests. All 8 tests pass and ruff reports no problems.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index ce3dbff8..dbe68a56 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -268,7 +268,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work and copies it to origin.git, a folder on this computer, but a step after that fails.", + "story": "Claude saves the work and copies it to origin.git, a folder on this computer, but a check after that fails.", "director": "git commit -q -F - · git push · 1 error", "balloon": null, "tests": null, 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 e936eb93..6c379b68 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -84,7 +84,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 24 s · 10 tool calls · 2 files changed", - "balloon": "slugify.mjs and its test file are written, all 4 tests pass, and both files are committed. First run (deliberately failing): the \"strips leading and trailing hyphens\" test expected '-hello-world'. That's wrong against the spec, and the run ended with 3 passed, 1 failed, exit code 1. Set git config user.name if you want a different name on future commits.", + "balloon": "slugify.mjs and its test file are written, all 4 tests pass, and both files are committed. Set git config user.name if you want a different name on future commits.", "tests": null, "roundsOk": null, "printed": null, @@ -152,7 +152,7 @@ "ok": null, "story": "Done in 21 seconds.", "director": "turn_duration · 21 s · 5 tool calls · 1 file changed", - "balloon": "I added a fifth test for slugify(''). The result was 4 passed, 1 failed, exit code 1.", + "balloon": "I added a fifth test for slugify(''). All 5 tests now pass, and the change is committed.", "tests": null, "roundsOk": null, "printed": null, 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 bf7e4b2e..fc1659ce 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -150,7 +150,7 @@ "ok": null, "story": "Done in 6 minutes.", "director": "turn_duration · 6 min 25 s · 26 tool calls · 13 files changed", - "balloon": "The inventory/ package is built and committed. --csv is part of the CLI commit rather than its own commit, because you asked for it before that module existed. Known quirks: remove pear prints the name as you typed it, not as it was stored (\"Pear\"). Because of how the temporary file is created, the saved JSON file is readable only by its owner (permissions 0600).", + "balloon": "The inventory/ package is built and committed. Decisions you may want to change: I committed CLAUDE.md. .claude/settings.local.json, .codeman-agent-case.json and the default inventory.json are gitignored. --csv is part of the CLI commit rather than its own commit, because you asked for it before that module existed. Known quirks: remove pear prints the name as you typed it, not as it was stored (\"Pear\"). Because of how the temporary file is created, the saved JSON file is readable only by its owner (permissions 0600).", "tests": null, "roundsOk": null, "printed": null, @@ -305,7 +305,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 16 s · 16 tool calls · 2 files changed", - "balloon": "I ran all 16 steps in order, one tool call each. Two places where I didn't follow your wording exactly: Step 6: this session has no Grep tool, so I ran grep -rn 'def ' inventory/ through Bash instead. It found the same matches.", + "balloon": "I ran all 16 steps in order, one tool call each. Two places where I didn't follow your wording exactly: Step 6: this session has no Grep tool, so I ran grep -rn 'def ' inventory/ through Bash instead. It found the same matches. Steps 5, 10 and 15: I added a second -m with the Co-Authored-By: Claude Opus 5.5 line to each commit, as the earlier commits have. The commit subjects are exactly as you wrote them.", "tests": null, "roundsOk": null, "printed": null, 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 9ec39595..a4530773 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -86,7 +86,7 @@ "ok": null, "story": "Done in 3 minutes.", "director": "turn_duration · 3 min 13 s · 13 tool calls · 3 files changed", - "balloon": "I built wcount.py and test_wcount.py. What failed and how I fixed it: one test failed on the first run.", + "balloon": "I built wcount.py and test_wcount.py. All 19 tests pass (19 passed in 0.46s) and ruff check . is clean. What failed and how I fixed it: one test failed on the first run.", "tests": null, "roundsOk": null, "printed": null, 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 db3e92dd..42f4b940 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -94,7 +94,7 @@ { "kind": "save", "ok": true, - "story": "Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a step after that fails.", + "story": "Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a check after that fails.", "director": "git commit -q -F - · git push -u origin main · 1 error", "balloon": null, "tests": null, @@ -105,7 +105,7 @@ { "kind": "check", "ok": true, - "story": "The tests pass.", + "story": "The tests pass in a fresh copy made from remote.git.", "director": "python3 -m pytest -q -p no:cacheprovider) → ok · 19 passed · then 1 second opinion", "balloon": null, "tests": { 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 15718d16..59b81325 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 @@ -104,7 +104,7 @@ "ok": null, "story": "Claude's part is done in 3 minutes. Its helper finished later and reported back.", "director": "turn_duration · 2 min 44 s · 11 tool calls · 5 files changed · 1 helper", - "balloon": "I've built csvstat.py, a command-line tool that prints a CSV's row count and the min, max and mean of each column. A background helper is writing README.md; I didn't wait for it, so it isn't confirmed yet. Problem: numbers are shown to only 6 significant digits. Once it has finished, the fix is a one-line formatting change and an update to the README example; say if you want it.", + "balloon": "I've built csvstat.py, a command-line tool that prints a CSV's row count and the min, max and mean of each column. Its 17 pytest tests pass, the linter (ruff) is clean, and the dry run on a sample CSV worked. A background helper is writing README.md; I didn't wait for it, so it isn't confirmed yet. Problem: numbers are shown to only 6 significant digits. Once it has finished, the fix is a one-line formatting change and an update to the README example; say if you want it.", "tests": null, "roundsOk": null, "printed": null, 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 d92b5ef6..8561e6ee 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -92,7 +92,7 @@ "ok": null, "story": "Done in 3 minutes.", "director": "turn_duration · 2 min 39 s · 10 tool calls · 3 files changed", - "balloon": "I built the pomodoro timer. Opening the page: it won't work if you double-click index.html. Serve the folder instead, for example with python3 -m http.server in the folder, then open http://localhost:8000. I didn't open it in a browser; I only checked that its script is valid.", + "balloon": "I built the pomodoro timer. All 7 tests pass under node --test, and everything is committed on main as c4e7b0b feat: add pomodoro timer with tests. Opening the page: it won't work if you double-click index.html. Serve the folder instead, for example with python3 -m http.server in the folder, then open http://localhost:8000. I didn't open it in a browser; I only checked that its script is valid.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index d05bcb91..0f7c5a56 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -127,7 +127,7 @@ describe('Story Mode on real sessions', () => { // One commit, then a push in a command of its own: one commit, not two. // The folder had no repository: the first save starts one, and the reader is told. story: - 'Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a step after that fails.', + 'Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a check after that fails.', }); }); @@ -142,7 +142,7 @@ describe('Story Mode on real sessions', () => { 'Claude saves the work: "add calc_stats.mode with smallest-value tie-breaking", then copies it to origin.git, a folder on this computer.' ); expect(third.panels.find((p) => p.kind === 'save')?.story).toBe( - 'Claude saves the work and copies it to origin.git, a folder on this computer, but a step after that fails.' + 'Claude saves the work and copies it to origin.git, a folder on this computer, but a check after that fails.' ); }); diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 53221768..39c9a735 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -886,6 +886,29 @@ describe('story-mode.js overlay', () => { expect(note.textContent).toBe('after asking a stronger AI model for a second opinion'); }); + it('a second opinion that came back with an error says no answer came', async () => { + const failed = { + 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_tool_result_error', error_code: 'unavailable' }, + }, + ], + }, + }; + boot(storyOf([prompt('fix it'), failed, ...edit('e1', '/work/demo-shop/a.py', ['+a = 1']), end(1000)])); + app.openStoryMode(SID); + await settle(); + expect($('.story-panel[data-kind="fix"] .story-cap-note')!.textContent).toBe( + 'after trying to ask a stronger AI model for a second opinion (no answer came)' + ); + }); + it('the final panel counts the commits of the whole turn, and says when the work was uploaded', async () => { const chipsFor = async (to: string) => { boot( diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 07c29b15..42165391 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1163,6 +1163,75 @@ describe('buildStoryPage', () => { expect(page).toMatchObject({ openNote: 'catch', asks: true }); }); + it('the closing balloon keeps the outcome, the items a list announces and what an offer points back to (round 10)', () => { + const last = (text: string) => pageOf([prompt('go'), said(text), end(1000)]).panels.at(-1)!.balloon!.text; + const filler = 'It reads the file and prints the counts for each column, one line per column. '.repeat(3); + // The outcome after the first sentence, and a test count ("1 failed") is no failed step. + expect( + last( + [ + 'I added a fifth test.', + 'All 5 tests now pass, and the change is committed.', + filler, + '- First run: the result was 4 passed, 1 failed.', + ].join('\n') + ) + ).toBe('I added a fifth test. All 5 tests now pass, and the change is committed.'); + // "Two places…:" brings both list lines, not only the first. + expect( + last( + [ + 'I ran all 16 steps in order, one tool call each.', + filler, + "Two places where I didn't follow your wording exactly:", + '- Step 6: there is no Grep tool, so I ran grep through Bash. It found the same matches.', + '- Steps 5 and 10: I added a co-author line to each commit.', + ].join('\n') + ) + ).toBe( + "I ran all 16 steps in order, one tool call each. Two places where I didn't follow your wording exactly: Step 6: there is no Grep tool, so I ran grep through Bash. It found the same matches. Steps 5 and 10: I added a co-author line to each commit." + ); + // An offer whose "it" is further back brings the start of its paragraph. + expect( + last( + [ + 'I added a --prefix option.', + filler, + 'Suffix swap: the two files with the same time would trade their _1 suffix. This was already in the script before. Sorting byte by byte would fix it. Want me to add that?', + ].join('\n') + ) + ).toBe( + 'I added a --prefix option. Suffix swap: the two files with the same time would trade their _1 suffix. … Sorting byte by byte would fix it. Want me to add that?' + ); + // A decision left to you is something for you. + expect(keyKindOf('Decisions you may want to change: I committed CLAUDE.md.')).toBe('offer'); + }); + + it('a check after a push is named a check, and tests in a fresh copy say so', () => { + const page = pageOf([ + prompt('ship it'), + ...bash('b1', 'git commit -qm "add a"', 'Commit', ''), + ...bash( + 'b2', + 'git push -u origin main && git remote -v && git -C remote.git log --oneline', + 'Push', + 'Exit code 128\nTo ./remote.git\n * [new branch] main -> main\nfatal: your current branch does not have any commits yet', + true + ), + ...bash( + 'b3', + 'C=/tmp/x && git clone -q remote.git "$C" && (cd "$C" && python3 -m pytest -q)', + 'Fresh copy', + '19 passed in 0.11s' + ), + end(1000), + ]); + expect(page.panels.find((p) => p.kind === 'save')!.caption.story).toMatch(/, but a check after that fails\.$/); + expect(page.panels.find((p) => p.kind === 'check')!.caption.story).toBe( + 'The tests pass in a fresh copy made from remote.git.' + ); + }); + it("a run panel's terminal card shows what the command printed, never what looks like a secret", () => { const page = pageOf([ prompt('dry run please'),