diff --git a/web/src/fixtures/panes/claude--draft-paste-split-partial.txt b/web/src/fixtures/panes/claude--draft-paste-split-partial.txt new file mode 100644 index 00000000..4394dee5 --- /dev/null +++ b/web/src/fixtures/panes/claude--draft-paste-split-partial.txt @@ -0,0 +1,7 @@ + Ctrl+Y to paste deleted text +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ [Pasted text #3 +5 lines] TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker · restart with CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1 to keep future transcripts + [Opus·medium] ~/playground/collie-demo-herd/juniper + paste again to expand diff --git a/web/src/fixtures/panes/claude--draft-paste-split-tail.txt b/web/src/fixtures/panes/claude--draft-paste-split-tail.txt new file mode 100644 index 00000000..adad48d0 --- /dev/null +++ b/web/src/fixtures/panes/claude--draft-paste-split-tail.txt @@ -0,0 +1,6 @@ +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ [Pasted text #3 +5 lines] TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta TAIL-THREE-echo-foxtrot +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker · restart with CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1 to keep future transcripts + [Opus·medium] ~/playground/collie-demo-herd/juniper + paste again to expand diff --git a/web/src/lib/harness/claude/chrome.test.ts b/web/src/lib/harness/claude/chrome.test.ts index e91c4703..3ab7ff39 100644 --- a/web/src/lib/harness/claude/chrome.test.ts +++ b/web/src/lib/harness/claude/chrome.test.ts @@ -696,6 +696,10 @@ describe("real corpus — pinned so any change to the walk shows up as a diff", { fixture: "draft-footer-single", statusRows: 2, draft: "remember to update the changelo", stripped: 9 }, { fixture: "draft-footer-wrapped", statusRows: 2, draft: "this stranded draft is long eno", stripped: 11 }, { fixture: "draft-paste-placeholder", statusRows: 2, draft: "[Pasted text #3 +3 lines]", stripped: 7 }, + // The split shape (#110): a token plus the literal tail beside it, captured complete and + // half-arrived. Three status rows here — the sandbox pane also carries a transcript warning. + { fixture: "draft-paste-split-partial", statusRows: 3, draft: "[Pasted text #3 +5 lines] TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta", stripped: 7 }, + { fixture: "draft-paste-split-tail", statusRows: 3, draft: "[Pasted text #3 +5 lines] TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta TAIL-THREE-echo-foxtrot", stripped: 7 }, { fixture: "draft-wrapped", statusRows: 2, draft: "this stranded draft is long eno", stripped: 10 }, { fixture: "fresh-idle", statusRows: 2, draft: null, stripped: 47 }, { fixture: "permission-bash", statusRows: 0, draft: null, stripped: 0 }, diff --git a/web/src/lib/harness/claude/paste.test.ts b/web/src/lib/harness/claude/paste.test.ts index f72d2378..d49df468 100644 --- a/web/src/lib/harness/claude/paste.test.ts +++ b/web/src/lib/harness/claude/paste.test.ts @@ -78,6 +78,44 @@ describe("pasteCarriesSend — rejections (the guard stays shut)", () => { expect(pasteCarriesSend("x".repeat(400), "[Pasted text #3]")).toBe(false); }); + it("rejects a tail that stops SHORT of the end of what we sent (#110)", () => { + // THE partial-arrival false positive. Live-probed 2026-08-17 (collie-demo, pane `w6:p1`): the + // head collapsed into a token and two of three tails arrived literally. `Σ M ≤ S` passes (the + // tail's own newlines were never in the token's count), and the truncated tail is still a + // prefix-ordered substring, so the indexOf loop passes too — the trailing text being the END of + // our message is the only thing that separates this screen from a complete one. + const sent = `${multiline(5)} TAIL-ONE-alpha TAIL-TWO-bravo TAIL-THREE-charlie`; + expect(pasteCarriesSend(sent, "[Pasted text #3 +5 lines] TAIL-ONE-alpha TAIL-TWO-bravo")).toBe( + false, + ); + // …and the complete arrival of the very same send still accepts. + expect( + pasteCarriesSend(sent, "[Pasted text #3 +5 lines] TAIL-ONE-alpha TAIL-TWO-bravo TAIL-THREE-charlie"), + ).toBe(true); + }); + + it("rejects a tail truncated MID-WORD, and a lone trailing scrap", () => { + const sent = `${multiline(3)} and then the tail four`; + expect(pasteCarriesSend(sent, "[Pasted text #1 +3 lines] and then the tail fo")).toBe(false); + expect(pasteCarriesSend(sent, "[Pasted text #1 +3 lines] x")).toBe(false); + }); + + it("still accepts a tail the box WRAPPED — the suffix is checked whitespace-stripped", () => { + // The wrap falls anywhere, including inside the tail, and extractInputDraft space-joins the rows. + const sent = `${multiline(3)} and then the tail four`; + expect(pasteCarriesSend(sent, "[Pasted text #1 +3 lines] and then the ta il fo ur")).toBe(true); + }); + + it("keeps today's looser rule when the draft ends ON a token", () => { + // The end of our message is inside the token there, so there is nothing visible to compare and + // the tightening has nothing to bite on. Documented hole (see paste.ts) — rejecting a shape we + // cannot read would turn working sends into permanent stalls, the worse of the two failures. + const sent = `${multiline(3)} a literal middle bit and more that collapsed`; + expect( + pasteCarriesSend(sent, "a literal middle bit[Pasted text #2 +3 lines]"), + ).toBe(true); + }); + it("rejects a draft with no token at all (the generic matcher's job, not ours)", () => { expect(pasteCarriesSend(multiline(3), "an unrelated leftover line")).toBe(false); expect(pasteCarriesSend(multiline(3), "")).toBe(false); diff --git a/web/src/lib/harness/claude/paste.ts b/web/src/lib/harness/claude/paste.ts index 6c94a818..a0a37f13 100644 --- a/web/src/lib/harness/claude/paste.ts +++ b/web/src/lib/harness/claude/paste.ts @@ -24,6 +24,12 @@ // * A PTY chunk split can leave `placeholder` + a literal tail in one draft (observed: // `[Pasted text #1 +3 lines]xxxxx… four`). Rapid consecutive chunks usually merge into ONE // placeholder carrying the total newline count. +// * In that split shape the token comes FIRST and the literal tail after it, so the last thing on +// the row is the last thing that arrived — re-probed 2026-08-17 (collie-demo, pane `w6:p1`, +// 200-col PTY), where a send whose final chunk never landed showed +// `[Pasted text #3 +5 lines] TAIL-ONE… TAIL-TWO…` and the complete one added `TAIL-THREE…`. +// Both captures are in the corpus (`claude--draft-paste-split-{partial,tail}.txt`); rule 5 below +// is what tells them apart. // * The token WRAPS arbitrarily inside the box and `extractInputDraft` space-joins wrapped rows, so // a wrap can fall mid-token (`…+3 li` / `nes]`). Every match here therefore runs on a // whitespace-STRIPPED normalisation — never on the space-joined raw, which would miss the wrap. @@ -55,6 +61,11 @@ interface Scan { /** The literal text between/around the tokens, in screen order, whitespace already stripped. Never * contains an empty string, so `fragments.length === 0` IS the fully-collapsed shape. */ fragments: string[]; + /** The LAST fragment when the draft ends in literal text rather than in a token — i.e. what the + * screen shows as the final thing typed. `null` when the draft ends on a token (or holds no + * literal text at all), because then the end of the message is inside a token and invisible. This + * is the one place the "is the tail complete?" question can be asked at all. */ + trailing: string | null; } function stripWhitespace(s: string): string { @@ -74,8 +85,12 @@ function scan(stripped: string): Scan { if (m.index > cursor) fragments.push(stripped.slice(cursor, m.index)); cursor = m.index + m[0].length; } - if (cursor < stripped.length) fragments.push(stripped.slice(cursor)); - return { tokens, lines, fragments }; + let trailing: string | null = null; + if (cursor < stripped.length) { + trailing = stripped.slice(cursor); + fragments.push(trailing); + } + return { tokens, lines, fragments, trailing }; } /** @@ -94,7 +109,22 @@ function scan(stripped: string): Scan { * 3. when the draft is NOTHING but tokens (the fully-collapsed shape), the counts match exactly * (`Σ M === S`). For a long single-line send that means S = 0, i.e. the M-less form; * 4. every literal fragment beside the tokens appears in what we sent, IN ORDER — the split - * token+tail shape, where the tail is the part of our message the chunk boundary left uncollapsed. + * token+tail shape, where the tail is the part of our message the chunk boundary left uncollapsed; + * 5. when the draft ENDS in literal text, that trailing text is the END of what we sent, not merely + * somewhere inside it (#110). Rules 2 and 4 both pass a PARTIALLY arrived send: `Σ M ≤ S` is + * deliberately loose (the tail's own newlines are not in the token's count, and they cannot be + * recovered — the box wraps and `extractInputDraft` space-joins the rows, so no newline survives + * to be counted), and a truncated tail is still a prefix-ordered substring, so every `indexOf` + * succeeds. The suffix is what distinguishes "the tail we can see is all the tail there is" from + * "later chunks are still missing". Live-probed 2026-08-17 (collie-demo, pane `w6:p1`): + * `[Pasted text #3 +5 lines] TAIL-ONE… TAIL-TWO…` for a message ending `…TAIL-THREE-echo-foxtrot` + * was accepted before this rule and fired Enter on a half-arrived send. + * + * A draft that ends ON a token keeps rules 1–4 only: the end of our message is then inside a token, + * where nothing is visible to compare, and the tightening has nothing to bite on. That residual hole + * is deliberate — it was never observed (truncation shows up as a literal dribble, not as a collapse), + * and rejecting a shape we cannot read would convert working sends into permanent stalls, which is the + * worse failure of the two. * * Anything inconsistent returns false and the caller keeps today's behaviour: no submit key, draft * kept, "didn't reach the input box". Guessing here would fire Enter at a screen we cannot read. @@ -102,7 +132,7 @@ function scan(stripped: string): Scan { export function pasteCarriesSend(sent: string, draft: string): boolean { const d = stripWhitespace(draft); const s = stripWhitespace(sent); - const { tokens, lines, fragments } = scan(d); + const { tokens, lines, fragments, trailing } = scan(d); if (tokens === 0) return false; const newlines = countNewlines(sent); @@ -111,6 +141,12 @@ export function pasteCarriesSend(sent: string, draft: string): boolean { if (lines > newlines) return false; if (fragments.length === 0) return lines === newlines; + // The draft ends in literal text, so the last thing the screen shows IS the last thing that + // arrived — and it therefore has to be the last thing we sent. Anything else means bytes are still + // missing (#110). No length exemption here: `extractInputDraft` trims the row, so a trailing scrap + // that is not the end of our message is a screen we do not understand, not wrap debris. + if (trailing !== null && !s.endsWith(trailing)) return false; + // Chained indexOf: each fragment must occur after the previous one, so a draft that shuffles our // words around (a different message that happens to share vocabulary) is rejected. let at = 0; diff --git a/web/src/lib/harness/conformance.test.ts b/web/src/lib/harness/conformance.test.ts index 7ed00ff5..ad04598f 100644 --- a/web/src/lib/harness/conformance.test.ts +++ b/web/src/lib/harness/conformance.test.ts @@ -34,6 +34,10 @@ const NEUTRAL = [ // A long send that Claude collapsed into `[Pasted text #3 +3 lines]`: still an input box holding a // draft, never a dialog. Pinned below (.adr/0010) — the token must not read as a modal. "claude--draft-paste-placeholder.txt", + // The SPLIT shape of the same thing — token + the literal tail a chunk boundary left uncollapsed — + // captured complete and half-arrived. Still an input box with a draft in it; pinned below (#110). + "claude--draft-paste-split-tail.txt", + "claude--draft-paste-split-partial.txt", // Input boxes with the background-agents footer below them — still composer chrome (stripped), not a // dialog, so they must stay raw / lift no interactive block. "claude--draft-footer-empty.txt", @@ -86,6 +90,45 @@ describe("claude--draft-paste-placeholder.txt — a collapsed long send is compo }); }); +// The SPLIT shape (#110), both halves captured live on 2026-08-17 in the collie-demo sandbox (pane +// `w6:p1`, 200-col PTY, Claude Code current): a long multi-line head collapsed into one token, then +// two short tails typed after a pause so they stayed literal beside it. The COMPLETE capture has the +// third tail as well; the PARTIAL one is the same screen with that last chunk never arriving. +// +// This pair is the whole point of the fix: before it, `draftCarriesSend` said TRUE to both, so the +// guard pressed Enter on a message that was still missing its last chunk and Collie reported success. +// The complete capture is the positive control — the tightening must not turn a real send into a +// permanent "didn't reach the input box" stall, which is the worse failure of the two. +describe("claude--draft-paste-split-*.txt — a token + literal tail, complete vs half-arrived", () => { + const head = Array.from({ length: 6 }, (_, i) => `line ${String(i).padStart(2, "0")} ${"abcdefghij".repeat(14)}`).join("\n"); + const sent = `${head} TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta TAIL-THREE-echo-foxtrot`; + const draftOf = (fixture: string) => + claudeAdapter.extractInputDraft( + splitLines(parseAnsi(readFileSync(join(PANES_DIR, fixture), "utf8"))), + )!; + + it("reads the token AND the literal tail off the ❯ line", () => { + expect(draftOf("claude--draft-paste-split-tail.txt")).toBe( + "[Pasted text #3 +5 lines] TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta TAIL-THREE-echo-foxtrot", + ); + expect(draftOf("claude--draft-paste-split-partial.txt")).toBe( + "[Pasted text #3 +5 lines] TAIL-ONE-alpha-bravo TAIL-TWO-charlie-delta", + ); + }); + + it("accepts the COMPLETE arrival as send evidence", () => { + expect(claudeAdapter.draftCarriesSend!(sent, draftOf("claude--draft-paste-split-tail.txt"))).toBe( + true, + ); + }); + + it("REFUSES the partial arrival — the visible tail is not the end of what we sent", () => { + expect( + claudeAdapter.draftCarriesSend!(sent, draftOf("claude--draft-paste-split-partial.txt")), + ).toBe(false); + }); +}); + // A focused unit test of the grammar validator itself — the load-bearing helper the suite leans on. describe("isValidHerdrKey", () => { it("accepts single literal chars, bare special keys, and modifier chords", () => {