Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions web/src/fixtures/panes/claude--draft-paste-split-partial.txt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions web/src/fixtures/panes/claude--draft-paste-split-tail.txt
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions web/src/lib/harness/claude/chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
38 changes: 38 additions & 0 deletions web/src/lib/harness/claude/paste.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
44 changes: 40 additions & 4 deletions web/src/lib/harness/claude/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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 };
}

/**
Expand All @@ -94,15 +109,30 @@ 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.
*/
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);
Expand All @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions web/src/lib/harness/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading