fix: deliver long CLI passthrough prompts intact (#3894)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Dmitry Koryakov
2026-09-24 15:45:00 +02:00
committed by GitHub
parent d602745281
commit 5cb908e4ad
28 changed files with 1129 additions and 143 deletions
@@ -0,0 +1,134 @@
---
created: 2026-09-22
status: complete
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
system_design:
- ../../specs/cli/system-design/passthrough-prompt-delivery.md
legacy_specs: []
---
# Implementation Plan: Passthrough Prompt Delivery Integrity
## Overview
A prompt delivered to a CLI passthrough session loses whole leading read-sized
blocks of its body. Measured against a live instance, every agent-to-agent
message longer than one read arrived at the agent missing exactly N x 1022
bytes from the front, with no error on any surface. The planner writes the whole
body as one unframed PTY write, so the receiving TUI has to absorb a burst many
times larger than one terminal read and silently drops the reads it cannot take.
Two work orders correct this in the one place that plans PTY writes. The first
restores bracketed-paste framing for TUIs that accept it, which a probe against
the real CLI showed carries 16 KB in a single write without loss. The second
paces the remaining unframed path so a body can never exceed one read per write.
## Scope
### In scope
- Frame passthrough prompt bodies with bracketed paste when the agent accepts
it, keeping the submit keystroke a separate delayed write.
- Neutralize framing terminators inside the body.
- Split unframed bodies into rune-safe writes within the host read budget, with
an inter-write delay.
- Update the agent capability records that currently disable framing, and the
tests that pin the superseded behavior.
### Out of scope
- The structured (non-passthrough) prompt path.
- Operator keystrokes typed into the mirrored terminal view.
- Message wrapping, queueing, delivery mode, and mid-turn steering.
- Reading the terminal back to acknowledge delivery.
- The local hotfix build and install on the operator's instance, which happens
outside the repository and is tracked in the session that requested it.
## Technical approach
`PlanPassthroughStdinChunks` stays the single planner and stays pure. It gains
three rules: neutralize framing terminators in the body; frame the body when the
capability record allows it and the body is multi-line or larger than the
raw-safe write size; otherwise emit the body as consecutive rune-safe writes no
larger than the raw-safe size, each continuation carrying an inter-write delay.
The submit keystroke keeps its own trailing chunk and its existing delay.
Callers already loop over planned chunks and honor `DelayBefore`, so no caller
changes. The raw-safe write size is set below the smallest supported host read
capacity (1022 bytes on macOS); 400 bytes with a 30 ms inter-write delay is the
combination verified against the real CLI.
`claude_acp.go` and `tui_agent.go` stop setting `DisableBracketedPaste`. The
field stays in the capability record as the lever for a TUI that genuinely
cannot accept framing, and work order 02 makes that path safe.
## Tests
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1`: planner tests in
`apps/backend/internal/agent/agents/passthrough_payload_test.go` assert the
concatenated chunk bodies reproduce the prompt exactly, for a body far larger
than one read, on both the framed and the unframed path.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.2`: a framed body is one chunk
carrying start and end markers; an unframed body is consecutive chunks in
order.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.3`: the submit keystroke remains the
final, separate chunk with `DelayBefore` equal to the configured submit delay.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.4`: with `DisableBracketedPaste` set,
no chunk exceeds the raw-safe size and each continuation carries the
inter-write delay.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.5`: a body containing a framing
terminator is neutralized before framing, and the marker does not survive into
a delivered chunk.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.6`: covered by the planner being the
single source for all callers; existing caller tests in
`manager_passthrough_autoinject_test.go` and
`executor_passthrough_prompt_test.go` keep that wiring honest.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.7`: existing passthrough prompt error
handling in `executor_passthrough_prompt_test.go` already asserts a write
failure surfaces to the caller; work order 02 keeps it passing with several
writes in flight.
## E2E tests
No Playwright scenario is added. The defect lives between the Go planner and a
vendor TUI's stdin handling, which Playwright does not observe. The behavioral
check that matters is the manual TUI probe recorded in work order 01, plus the
post-deploy comparison of stored message text against the agent's own
transcript.
## Work orders
- [x] [Task 01: Frame passthrough prompt body](task-01-frame-passthrough-prompt-body.md) (done)
- [x] [Task 02: Pace unframed passthrough writes](task-02-pace-unframed-passthrough-writes.md) (done)
## Verification results
Both work orders completed. A probe against the real vendor CLI confirmed a
framed 8 KB body written in one PTY write is accepted whole and submitted by the
delayed submit keystroke. `go test ./internal/agent/agents/` and
`./internal/orchestrator/executor/` pass; `go vet` and `gofmt` are clean and the
backend builds. Failures in `./internal/agent/runtime/lifecycle/` (worktree
preparer, Kubernetes prepare-script, and SSH identity-agent tests) are
environment-dependent: the same twelve tests fail on a pristine checkout of the
base branch, with and without this change. `golangci-lint` was not run: it is
not installed on the development machine.
End-to-end on a live instance running the patched `v0.95.0` build, against a
Claude CLI passthrough session: the initial prompt injector wrote 10174 bytes
and the agent's own transcript recorded 10174 bytes, starting at the first byte,
with all 60 line markers present. A `message_task_kandev` message of 9213 bytes,
the path that exposed the defect, arrived byte-for-byte identical to the stored
message, and the agent reported every marker from first to last. Before the
correction, every stored agent message above 5 KB had lost between 1022 and
5110 bytes from its start.
## Risks
- A TUI that does not enable bracketed-paste mode would render the markers as
literal text. Work order 01 confirms the framed body is accepted and submitted
by the real CLI before the capability records change.
- Custom TUI agents inherit the same change as the built-in Claude record. They
share the same TUI family but were not individually probed.
- The unframed path becomes slower in proportion to body size, which is
intentional: correctness over latency for a path with no acknowledgement.
@@ -0,0 +1,123 @@
---
id: "01-frame-passthrough-prompt-body"
title: "Frame passthrough prompt body"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
acceptance_criteria:
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.2
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.3
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.5
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.6
system_design:
- ../../specs/cli/system-design/passthrough-prompt-delivery.md
---
# Task 01: Frame Passthrough Prompt Body
## Summary
Restore bracketed-paste framing for passthrough agents that accept it, so a
prompt body larger than one terminal read reaches the TUI as one paste instead
of a burst it drops reads from. Neutralize framing terminators inside the body
first, and keep the submit keystroke a separate delayed write.
## In scope
- Add failing planner tests for a body far larger than one read, for framing
markers, and for a body containing a framing terminator.
- Neutralize framing terminators in the body inside the planner.
- Frame the body when the capability record allows framing and the body is
multi-line or larger than the raw-safe write size.
- Stop setting `DisableBracketedPaste` in the built-in Claude capability record
and in the custom TUI agent constructor.
- Update the existing tests that pin the superseded unframed behavior.
## Out of scope
- The unframed delivery path, which work order 02 paces.
- Caller changes: the initial-prompt injector, workflow step handler, and
session prompt path already loop over planned chunks.
- Detecting bracketed-paste support from the terminal output stream.
## Acceptance
- A probe against the real vendor CLI confirms a framed body of at least 16 KB
written in one PTY write reaches the input intact and is submitted by the
delayed submit keystroke. If submission fails, stop and re-plan: the fallback
is the paced path from work order 02 for this agent too.
- Concatenating the planned body chunks reproduces the prompt exactly, with
framing markers only at the outer boundary and none surviving from the body.
- The submit keystroke remains the final separate chunk carrying the configured
submit delay, and small single-line bodies keep their current bytes.
## Verification
```bash
(cd apps/backend && go test ./internal/agent/agents/ -run 'TestPlanPassthroughStdin|TestBuildPassthroughPayload|TestNewTUIAgentDefaultsToInkSafePassthrough|TestClaudeACP' -count=1)
(cd apps/backend && go test ./internal/agent/agents/ ./internal/agent/runtime/lifecycle/ ./internal/orchestrator/executor/ -count=1)
(cd apps/backend && gofmt -l internal/agent/agents)
```
## Files likely touched
- `apps/backend/internal/agent/agents/passthrough_payload.go`
- `apps/backend/internal/agent/agents/passthrough_payload_test.go`
- `apps/backend/internal/agent/agents/claude_acp.go`
- `apps/backend/internal/agent/agents/tui_agent.go`
- `apps/backend/internal/agent/agents/tui_agent_test.go`
- `docs/plans/passthrough-prompt-delivery-integrity/plan.md`
- `docs/plans/passthrough-prompt-delivery-integrity/task-01-frame-passthrough-prompt-body.md`
## Dependencies
None.
## Risks
- `TestNewTUIAgentDefaultsToInkSafePassthrough` and the Claude body-verbatim
assertion in `passthrough_payload_test.go` encode the behavior being replaced.
Rewrite them to assert the new contract rather than deleting the coverage.
- The historical reason for disabling framing was a submit keystroke absorbed
into the paste. The separate delayed submit chunk already addresses it; the
probe in Acceptance is what proves it, not the commit history.
## Parallelism
`sequential`
## Inputs
- `REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001` and its system design.
- Diagnosis evidence: delivered bodies lost exactly N x 1022 bytes from the
front; a framed 16 KB body written once arrived complete in a probe against
the real CLI; an unframed 8 KB body lost roughly half its lines.
- Existing planner, capability records, and caller tests.
## Results
PROBE: An 8 KB body framed as a bracketed paste and written to the real CLI in
one PTY write was accepted whole, and the submit keystroke written 150 ms later
started the turn: the input box cleared and the agent moved to its thinking
state. Framing therefore does not break submission, so the design holds and the
paced fallback stays reserved for agents that refuse framing.
RED: Five behavioral failures. The Claude body chunk arrived unframed for a
multi-line prompt, an 11 KB multi-line body was not bracketed, a single-line
body past the raw-safe size was not bracketed, prompt-embedded paste markers
survived into the delivered body, and the custom TUI agent still disabled
framing.
GREEN: The planner now strips paste markers from the body, frames it when the
agent accepts framing and the body is multi-line or larger than
`passthroughRawSafeWriteBytes`, and keeps the submit byte a separate delayed
chunk. `claude_acp.go` and `tui_agent.go` no longer set
`DisableBracketedPaste`. `go test ./internal/agent/agents/` and
`./internal/orchestrator/executor/` pass. Failures in
`./internal/agent/runtime/lifecycle/` (worktree preparer and SSH identity-agent
tests) reproduce identically on a pristine checkout of the base branch and are
unrelated to this work order.
@@ -0,0 +1,115 @@
---
id: "02-pace-unframed-passthrough-writes"
title: "Pace unframed passthrough writes"
status: done
wave: 2
depends_on:
- "01-frame-passthrough-prompt-body"
plan: "plan.md"
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
acceptance_criteria:
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.4
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.7
system_design:
- ../../specs/cli/system-design/passthrough-prompt-delivery.md
---
# Task 02: Pace Unframed Passthrough Writes
## Summary
Make the remaining unframed path safe for an agent whose capability record
disables framing. Split the body into rune-safe writes no larger than the
raw-safe size and delay each continuation, so the receiving TUI never has to
absorb more than one terminal read at a time.
## In scope
- Add failing planner tests for an unframed body larger than the raw-safe size,
including a multi-byte body whose split would otherwise fall mid-character.
- Split the unframed body into ordered chunks within the raw-safe size, on rune
boundaries, each continuation carrying the inter-write delay.
- Name the raw-safe size and inter-write delay as constants with the host read
capacity they derive from.
- Keep the submit keystroke the final chunk with its own delay, after the last
body chunk.
- Confirm a write failure mid-body still surfaces to the caller.
## Out of scope
- The framed path from work order 01.
- Changing the capability records again; this path exists for agents that
genuinely cannot accept framing.
- Adaptive sizing or feedback from the terminal output stream.
## Acceptance
- With framing disabled, no planned body chunk exceeds the raw-safe size, the
concatenated chunks reproduce the prompt exactly, and no chunk ends inside a
multi-byte character.
- Every continuation body chunk carries the inter-write delay; the first body
chunk carries none.
- A write error on any chunk aborts the remaining writes and returns to the
caller as a failed delivery.
## Verification
```bash
(cd apps/backend && go test ./internal/agent/agents/ -run 'TestPlanPassthroughStdin' -count=1)
(cd apps/backend && go test ./internal/agent/agents/ ./internal/orchestrator/executor/ -count=1)
(cd apps/backend && gofmt -l internal/agent/agents)
```
## Files likely touched
- `apps/backend/internal/agent/agents/passthrough_payload.go`
- `apps/backend/internal/agent/agents/passthrough_payload_test.go`
- `docs/plans/passthrough-prompt-delivery-integrity/plan.md`
- `docs/plans/passthrough-prompt-delivery-integrity/task-02-pace-unframed-passthrough-writes.md`
## Dependencies
- Work order 01 establishes the framed path and the shared raw-safe size
constant this work order reuses.
## Risks
- Delivery time grows with body size on this path. Keep the constants in one
place with a comment naming the host read capacity, so a future change is a
deliberate trade rather than a guess.
- Splitting by bytes instead of runes would corrupt non-ASCII prompts, which is
exactly the traffic that exposed the original defect.
## Parallelism
`sequential`
## Inputs
- `REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001` and its system design.
- Probe evidence: unframed writes of 400 bytes spaced 30 ms apart delivered a
3 KB body with no loss, while one 8 KB unframed write lost about half of it.
- The planner and tests as left by work order 01.
## Results
RED: A 3.8 KB unframed body still planned as one 3800-byte write, and the
no-submit-delay path planned one 1201-byte write, both far past the raw-safe
size.
GREEN: `paceRawBody` splits an unframed body into writes of at most
`passthroughRawSafeWriteBytes`, cutting on rune boundaries, with
`passthroughRawWriteInterval` before each continuation. The submit sequence
stays its own delayed chunk when the agent sets a submit delay and otherwise
rides on the final body write. The rune-boundary test caught a real off-by-one:
a body whose length is an exact multiple of the write size indexed one byte past
the end. `go test ./internal/agent/agents/` and
`./internal/orchestrator/executor/` pass, `go vet ./internal/agent/agents/...`
is clean, `gofmt -l internal/agent/agents` is empty, and `go build ./...`
succeeds.
Not run: `golangci-lint`, which is not installed on this machine and was not
added. The changed functions stay well inside the documented limits (longest is
28 lines, nesting depth 4).