Skip to content

Type a long message as a paste, not as a burst of keystrokes (#277) - #279

Merged
scgopi merged 4 commits into
mainfrom
fix/277-send-head
Sep 4, 2026
Merged

Type a long message as a paste, not as a burst of keystrokes (#277)#279
scgopi merged 4 commits into
mainfrom
fix/277-send-head

Conversation

@scgopi

@scgopi scgopi commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Fixes #277.

node send dropped the head of every multi-chunk message, kept the tail, and printed delivered. Because the [graphcode] <sender>: prefix rode the lost head, grep '[graphcode]' over a transcript found only the undamaged messages — the bug hid from the one query you would use to look for it.

What it actually was

The 0.1.29 fix (d3f7c9a) that introduced chunking blamed the PTY's input queue. That was wrong, and the wrong cause is exactly what makes this look fixable by a smaller chunk.

Layer Test Result
zmx daemon + PTY + kernel 2 KB writes → raw-mode sink ✅ byte-identical
Same, reader stalled 300 ms per read ✅ byte-identical
Real Claude Code composer 2634 B sent 590 B arrived

Sweep at 2400 B total: chunk 1024 clipped; 896 / 768 / 640 / 512 / 384 all intact.

Chunking smaller is not a fix. The TUI's reads are not our writes — with the reader stalled 0.5 s, two 512-byte writes arrived as one 1022-byte read, and an agent mid-turn stalls far longer than the 150 ms inter-chunk beat. Any chunk size can coalesce into a burst above the threshold. That is why the field losses (1954 / 1096 / 2539 B) never landed on a chunk boundary.

The fix

A long message travels as a bracketed pasteESC[200~ … ESC[201~ tells the TUI where the payload begins and ends, so coalescing is harmless and no keystroke heuristic applies.

Short messages are untouched: one plain write, exactly as before. They are measured to arrive intact and are the overwhelming majority of what loops send, so the change is confined to the case that is broken today, where it can only be an improvement.

Backends — measured, not assumed

Sending 2686 B and asking the receiver whether the head token arrived:

Backend DECSET 2004h Old (plain chunks) New (bracketed paste)
Claude Code handles it ❌ head lost ✅ intact
Codex ✅ enabled ✅ intact ✅ intact
Copilot CLI ✅ enabled ✅ intact ✅ intact
OpenCode ✅ enabled not measured not measured

Codex and Copilot were measured not to have the bug — the point of testing them is that the new framing must not regress them, and it does not.

The other two things #277 asked for

  • Detectability. delivered has only ever meant "every zmx send exited 0", and that has twice been true of text that never arrived. Nothing this side can read back proves otherwise — the composer is not legible through zmx, and a message to a busy loop sits unsubmitted for the length of its turn — so the receipt is one the receiver can check: a long message now carries a trailer naming its length and its opening.
  • Attribution surviving a head loss. The trailer rides the tail, the half that survives, and repeats the [graphcode] token — so the audit grep stops being blind to exactly the messages it should find. The prefix stays on the head, where it reads naturally.

Also: flattened now drops ESC. Bare it is a composer's cancel key, and an ESC[201~ inside the payload would close the paste early and type the rest as the keystrokes this exists to avoid.

Test

MessageDeliveryTests sends a real over-2048-byte message through the production path into a real zmx session and compares byte for byte — not a test that the splitter splits, which is what the 0.1.29 tests asserted while the message vanished. Its reader models what was measured: it swallows a long run of plain keystrokes and honours a bracketed paste.

Reverting send to the old chunking makes it fail, with the long message arriving as the empty string:

✘ aLongMessageSurvivesAComposerThatSwallowsKeystrokeBursts
    Expectation failed: (arrived → "") == (expected(sent) → "[graphcode] Sender: <0000>…")
✔ aShortMessageStillTravelsAsPlainKeystrokes   ← unchanged path, passes either way

Gate

Check Result
Tests ✅ 1551 in 161 suites (main baseline 1541, +10)
swiftlint ✅ 0 errors
swift format --strict ✅ clean
Schemes built ✅ graphcode, graphcode-cli, graphcoded

🤖 Generated with Claude Code

https://claude.ai/code/session_0113hrPHjKoTfYiGsB2Hh6DM

scgopi and others added 2 commits September 3, 2026 21:56
`node send` dropped the head of every multi-chunk message, kept the tail, and
printed `delivered`. The receiver got something that began mid-sentence and read
as complete — and because the `[graphcode] <sender>:` prefix rode the lost head,
grepping a transcript for `[graphcode]` found only the messages that were fine.
The bug hid from the one query you would use to look for it. It corrupted a
review-findings handoff twice during #272/#273 before anyone noticed.

The 0.1.29 fix (`d3f7c9a`) that introduced chunking blamed the PTY's input queue.
That was wrong, and it is worth saying so in the file, because the wrong cause is
what makes this look fixed by a smaller chunk.

Measured against a real Claude Code session. Nothing below the TUI loses
anything: 2 KB writes reach a raw-mode sink byte-for-byte, and reach a sink that
stalls 300 ms between reads byte-for-byte too, because the zmx daemon buffers
what the kernel will not take and drains it on POLLOUT. The loss is the
composer's own input handling swallowing a large enough burst of keystrokes —
2634 bytes sent, 590 received, and a sweep at 2400 bytes total: 1024-byte chunks
clipped, 896 and below intact.

And chunking smaller does not fix it, which is the trap. The TUI's *reads* are
not our writes: with the reader stalled 0.5 s, two 512-byte writes arrived as one
1022-byte read, and an agent mid-turn stalls far longer than the 150 ms
inter-chunk beat. Any chunk size can coalesce into a burst above the threshold.
That is why the losses in the field (1954, 1096, 2539 bytes) never landed on a
chunk boundary.

So a long message now travels as a bracketed paste. `ESC[200~ … ESC[201~` tells
the TUI where the payload begins and ends, so coalescing is harmless and no
keystroke heuristic applies. Verified intact at 2600 bytes on Claude Code, and on
Codex and Copilot, which were measured *not* to have the bug — the framing has to
not regress them, and does not.

Short messages are untouched: one plain write, exactly as before. They are
measured to arrive intact and they are the overwhelming majority of what loops
send, so the change stays confined to the case that is broken today, where it can
only be an improvement.

Two smaller things the issue asked for. A long message now carries a trailer
naming its length and its opening, because `delivered` has only ever meant "every
`zmx send` exited 0" and that has twice been true of text that never arrived —
nothing this side can read back proves otherwise, so the receipt is one the
receiver can check. It rides the tail, the half that survives, which also gives
attribution back to a damaged message without moving the prefix off the head
where it reads naturally. And `flattened` now drops ESC: bare it is a composer's
cancel key, and inside a paste an `ESC[201~` in the payload would close the paste
early and type the rest as the keystrokes this exists to avoid.

The regression test sends a real over-2048-byte message through the production
path into a real zmx session and compares byte for byte — not a test that the
splitter splits, which is what the 0.1.29 tests asserted while the message
vanished. Its reader models what was measured: it swallows a long run of plain
keystrokes and honours a bracketed paste. Reverting `send` to the old chunking
makes it fail with the message arriving as the empty string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113hrPHjKoTfYiGsB2Hh6DM
@scgopi

scgopi commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Review of #279 — approved, and the diagnosis corrects mine

Cleared to merge, subject to the gate being green (see the one question at the end).

I filed #277 and got the mechanism wrong. This PR found the real one and proved it. That is worth stating plainly because the wrong cause was load-bearing: I attributed the loss to our writes exceeding a chunk limit, which makes "chunk smaller" look like a fix. It is not.

Layer Test Result
zmx daemon + PTY + kernel 2 KB writes → raw-mode sink ✅ byte-identical
same, reader stalled 300 ms ✅ byte-identical
real Claude Code composer 2634 B sent ❌ 590 B arrived

The decisive observation is that the TUI's reads are not our writes: with the reader stalled 0.5 s, two 512-byte writes arrived as one 1022-byte read. Any chunk size can coalesce past the threshold, and an agent mid-turn stalls far longer than the 150 ms inter-chunk beat. That also explains the field data I could not account for — the losses at 1954 / 1096 / 2539 B never landed on a chunk boundary, which under my theory they should have.

It further means d3f7c9a (0.1.29) fixed the symptom under a wrong cause and left the mechanism live. Worth knowing for anyone who touches this again.

The fix

Bracketed paste (ESC[200~ … ESC[201~) for anything over maxUnbracketedSendBytes = 1024; short messages still travel as plain keystrokes, so existing behaviour is untouched. Brackets ride on the first and last chunk rather than as separate writes, so coalescing inside the payload is harmless by construction rather than by timing.

Two details I want to praise specifically:

  • ESC handling. Typed as a keystroke it is a composer's cancel key, and an ESC[201~ occurring inside message text would terminate the paste early. Catching that is the difference between a fix and a new bug.
  • deliveryTrailer. This answers the part of node send drops the head of a multi-chunk message and reports it delivered #277 I flagged but could not solve: delivered has only ever meant "every zmx send exited 0", and that has now twice been true of a message whose text never arrived. The insight here is that the sender cannot verify — the composer is not legible through zmx — so the receipt is made checkable by the receiver: the message carries its own character count and opening 48 characters. A clipped message now announces that it was clipped. That is a better answer than the sender-side check I proposed.

The test

This is the standard, and it clears the bar #275 set.

  • Real zmx run -d session against the real binary, real PTY, python sink in raw mode.
  • Waits on a ready file rather than sleeping, with a comment explaining that racing tty.setraw would report head loss from the wrong cause — a test that could have manufactured the very bug it guards.
  • Asserts byte for byte, not contains — correct, because the failure being guarded is a message that looks complete with only its head missing.
  • Asserts the [graphcode] Sender: prefix survives, which is the specific evidence the bug used to destroy.
  • aLongMessageSurvivesAComposerThatSwallowsKeystrokeBursts models the measured swallow threshold and states it reproduces node send drops the head of a multi-chunk message and reports it delivered #277 before the fix.

One item, non-blocking

All three tests open with guard ZmxLocator.isInstalled else { return }, so on a machine without zmx they pass without executing. A skipped test reads as green. Given this suite is the whole proof, consider try #require(ZmxLocator.isInstalled) or an explicit skip so an environment that cannot run it says so rather than reporting success.

Question before merge

Please confirm the gate: test count, suites, and make check. Everything else is cleared.

Review point, and it is the same class of thing this suite exists to catch: all
three tests opened with `guard ZmxLocator.isInstalled else { return }`, so on a
machine without zmx they reported as *passing* without executing anything. A
green tick for a test that never ran is exactly the silent success #277 was.

`.enabled(if:)` on the suite reports them as skipped instead, which is the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113hrPHjKoTfYiGsB2Hh6DM
@scgopi

scgopi commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Review item addressed, and the backend table is now complete

The guard ZmxLocator.isInstalled else { return } point — fixed in 62b248a. You were right, and it is the same class of thing this suite exists to catch: a test that never ran reported as passing. The suite is now @Suite(.serialized, .enabled(if: ZmxLocator.isInstalled, "zmx is not installed")), so a machine without zmx reports skipped, which is the truth. All three guards are gone.

OpenCode measured — the one cell that said "not measured" in the PR body:

Backend DECSET 2004h Old (plain chunks) New (bracketed paste)
Claude Code handles it head lost ✅ intact
Codex ✅ enabled ✅ intact ✅ intact
Copilot CLI ✅ enabled ✅ intact ✅ intact
OpenCode ✅ enabled ✅ intact

Method: 2686 B sent, with an oracle at the tail asking the receiver to reply INTACT if the head token HEADSTART7 arrived and MISSING-HEAD if it did not. Claude Code is the only one of the four that loses the head — so #277 is Claude-Code-specific, and the reason to measure the other three was that the new framing must not regress backends that were already fine. It does not.

Gate — confirmed on 62b248a

Check Result
Tests 1551 tests in 161 suites passed (main baseline 1541, +10)
MessageDeliveryTests ✅ 3 of 3, ran for real (not skipped)
make check ✅ 170 violations, 0 serious
swiftlint ✅ 0 errors
swift format --strict ✅ clean
Schemes built graphcode, graphcode-cli, graphcoded

No new lint warnings: the one my test introduced (String(decoding:as:)) is gone too.

Fail-before check, re-confirmed. Reverting send to the old messageChunks chunking and rerunning:

✘ aLongMessageSurvivesAComposerThatSwallowsKeystrokeBursts
    Expectation failed: (arrived → "") == (expected(sent) → "[graphcode] Sender: <0000>…")
✘ anOversizedMessageArrivesByteForByte
✔ aShortMessageStillTravelsAsPlainKeystrokes   ← the unchanged path, green either way

One thing worth carrying forward

Note what reached me on this thread: your review arrived as mposer is not legible through zmx, so you made the receipt checkable by the RECEIVER…. Head clipped, mid-word, delivered reported — the bug reviewing its own fix. That is the last time it should happen for a long message; short ones were never affected.

The scope limit is worth stating plainly, since it is the honest edge: the fix removes the mechanism (a burst of keystrokes the composer is free to drop) rather than tuning a threshold, but bracketed paste is still something the receiving TUI has to honour. All four backends enable the mode, and three of the four are verified end-to-end above.

Ready to merge.

@scgopi
scgopi merged commit 5d13359 into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node send drops the head of a multi-chunk message and reports it delivered

1 participant