fix: deliver long CLI passthrough prompts intact (#3894)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -399,17 +399,19 @@ type PassthroughConfig struct {
|
||||
// and when routing chat-compose messages to the PTY. "\r" for most TUIs.
|
||||
// Empty inherits DefaultPassthroughSubmitSequence at PTY write sites.
|
||||
SubmitSequence string
|
||||
// DisableBracketedPaste sends prompt bytes verbatim (plus SubmitSequence).
|
||||
// Claude Code enables bracketed-paste *mode* (?2004h) in its Ink TUI; injecting
|
||||
// ESC[200~…ESC[201~ delimiters breaks input (nothing appears in the prompt).
|
||||
// DisableBracketedPaste sends the prompt body without ESC[200~…ESC[201~
|
||||
// delimiters. The planner then paces the body in writes that each fit within
|
||||
// one terminal read, because a TUI can drop whole reads of a larger unframed
|
||||
// burst. Set it only for a TUI that does not accept bracketed-paste input;
|
||||
// framed bodies arrive whole at any length.
|
||||
DisableBracketedPaste bool
|
||||
// SubmitDelay is the wait inserted before each non-first chunk when writing the
|
||||
// prompt+submit sequence to PTY stdin. Ink-based TUIs (Claude Code) detect a
|
||||
// SubmitDelay is the wait inserted before the separate submit chunk when
|
||||
// writing a prompt to PTY stdin. Ink-based TUIs (Claude Code) detect a
|
||||
// "paste burst" when many stdin bytes arrive in one read and absorb the
|
||||
// trailing \r into the pasted content instead of dispatching it as Enter.
|
||||
// Splitting the prompt body from the submit byte with a small delay forces the
|
||||
// submit to arrive as a discrete keystroke. 0 disables (other TUIs handle one
|
||||
// atomic write fine).
|
||||
// Writing the submit byte on its own after a small delay makes it arrive as
|
||||
// a discrete keystroke. 0 appends the submit sequence to the final body
|
||||
// write (other TUIs handle prompt and submit in one read).
|
||||
SubmitDelay time.Duration
|
||||
}
|
||||
|
||||
|
||||
@@ -52,24 +52,25 @@ func NewClaudeACP() *ClaudeACP {
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: claudeACPPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
ResumeFlag: NewParam("-c"),
|
||||
SessionResumeFlag: NewParam("--resume"),
|
||||
MCPStrategy: mcpconfig.ClaudeStrategy{},
|
||||
AutoInjectPrompt: true,
|
||||
SubmitSequence: "\r",
|
||||
DisableBracketedPaste: true,
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
ResumeFlag: NewParam("-c"),
|
||||
SessionResumeFlag: NewParam("--resume"),
|
||||
MCPStrategy: mcpconfig.ClaudeStrategy{},
|
||||
AutoInjectPrompt: true,
|
||||
SubmitSequence: "\r",
|
||||
// Claude Code's Ink TUI coalesces multi-byte stdin reads into a
|
||||
// paste burst, absorbing trailing "\r" into the input rather than
|
||||
// dispatching Enter. A short delay before the submit byte forces
|
||||
// it to arrive as a discrete keystroke. 150ms is just over Ink's
|
||||
// paste-detection window and still feels instant to the user.
|
||||
// paste burst, absorbing a trailing "\r" into the input rather
|
||||
// than dispatching Enter. A short delay before the submit byte
|
||||
// makes it arrive as a discrete keystroke; 150ms is just over
|
||||
// Ink's paste-detection window and still feels instant. Because
|
||||
// the submit byte is its own write, the body travels as a
|
||||
// bracketed paste, which the TUI absorbs whole at any length.
|
||||
SubmitDelay: 150 * time.Millisecond,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,18 +3,33 @@ package agents
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
bracketedPasteStart = "\x1b[200~"
|
||||
bracketedPasteEnd = "\x1b[201~"
|
||||
|
||||
// passthroughRawSafeWriteBytes caps one unframed PTY write below the host's
|
||||
// single-read capacity (1022 bytes on macOS). A TUI can drop whole reads of
|
||||
// an unframed burst without reporting it, so an unframed write never exceeds
|
||||
// one read. Larger bodies are framed as a bracketed paste, which the TUI
|
||||
// absorbs in one piece at any length.
|
||||
passthroughRawSafeWriteBytes = 400
|
||||
|
||||
// passthroughRawWriteInterval spaces consecutive unframed writes to give the
|
||||
// TUI time to drain one read before the next arrives. PTY writes provide no
|
||||
// acknowledgement from the receiving TUI, so this is an empirical pacing
|
||||
// policy rather than a delivery receipt.
|
||||
passthroughRawWriteInterval = 30 * time.Millisecond
|
||||
)
|
||||
|
||||
// PassthroughStdinChunk is one PTY stdin write planned for a passthrough prompt.
|
||||
// DelayBefore is sleep applied before writing this chunk; it is zero on the first
|
||||
// chunk and inherits PassthroughConfig.SubmitDelay on subsequent chunks. Splitting
|
||||
// the prompt body from the submit byte with a delay defeats Ink-style paste-burst
|
||||
// detection in TUIs like Claude Code (see PassthroughConfig.SubmitDelay).
|
||||
// DelayBefore is the pause applied before writing this chunk. The first chunk
|
||||
// carries none; continuation body chunks on the unframed path carry
|
||||
// passthroughRawWriteInterval; a separate submit chunk carries
|
||||
// PassthroughConfig.SubmitDelay so the submit byte arrives as a discrete keystroke
|
||||
// rather than being absorbed into an Ink-style paste burst.
|
||||
type PassthroughStdinChunk struct {
|
||||
Data string
|
||||
DelayBefore time.Duration
|
||||
@@ -26,35 +41,101 @@ func PassthroughSubmitSequence(cfg PassthroughConfig) string {
|
||||
return EffectiveSubmitSequence(cfg.SubmitSequence)
|
||||
}
|
||||
|
||||
// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write.
|
||||
// Multi-line prompts use bracketed paste so embedded newlines are not treated as
|
||||
// premature Enter presses, unless DisableBracketedPaste is set (Claude Code).
|
||||
func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string {
|
||||
submit := PassthroughSubmitSequence(cfg)
|
||||
if cfg.DisableBracketedPaste || !strings.Contains(prompt, "\n") {
|
||||
return prompt + submit
|
||||
// stripPasteMarkers removes bracketed-paste delimiters from a prompt before it
|
||||
// is delivered. A marker carried inside the body would end the paste early and
|
||||
// let the remainder reach the TUI as terminal input outside the prompt, which
|
||||
// for a CLI agent means unintended commands.
|
||||
//
|
||||
// Replace until stable: a single pass can be evaded by nesting a marker inside
|
||||
// itself (e.g. "\x1b[2\x1b[201~01~" collapses to a live marker after one pass).
|
||||
func stripPasteMarkers(value string) string {
|
||||
for strings.Contains(value, bracketedPasteStart) || strings.Contains(value, bracketedPasteEnd) {
|
||||
value = strings.ReplaceAll(value, bracketedPasteStart, "")
|
||||
value = strings.ReplaceAll(value, bracketedPasteEnd, "")
|
||||
}
|
||||
return bracketedPasteStart + prompt + bracketedPasteEnd + submit
|
||||
return value
|
||||
}
|
||||
|
||||
// framePassthroughBody reports whether the body travels as a bracketed paste.
|
||||
// Framing is what lets a body outgrow the host's single-read capacity: the TUI
|
||||
// absorbs a delimited paste in one piece, while an equally large unframed burst
|
||||
// loses whole reads. A short single-line body is written unframed.
|
||||
func framePassthroughBody(prompt string, cfg PassthroughConfig) bool {
|
||||
if cfg.DisableBracketedPaste {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(prompt, "\n") || len(prompt) > passthroughRawSafeWriteBytes
|
||||
}
|
||||
|
||||
// planBodyChunks plans the PTY writes that carry the prompt body, excluding the
|
||||
// submit sequence. A framed body is one write at any length; an unframed body is
|
||||
// paced so no single write exceeds what the TUI can absorb in one read.
|
||||
func planBodyChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk {
|
||||
if framePassthroughBody(prompt, cfg) {
|
||||
body := stripPasteMarkers(prompt)
|
||||
return []PassthroughStdinChunk{{Data: bracketedPasteStart + body + bracketedPasteEnd}}
|
||||
}
|
||||
return paceRawBody(prompt)
|
||||
}
|
||||
|
||||
// paceRawBody splits an unframed body into writes within the raw-safe size,
|
||||
// cutting on rune boundaries so no write ends inside a multi-byte character.
|
||||
// Every continuation carries the inter-write delay; the first carries none.
|
||||
func paceRawBody(body string) []PassthroughStdinChunk {
|
||||
if len(body) <= passthroughRawSafeWriteBytes {
|
||||
return []PassthroughStdinChunk{{Data: body}}
|
||||
}
|
||||
var chunks []PassthroughStdinChunk
|
||||
for len(body) > 0 {
|
||||
cut := passthroughRawSafeWriteBytes
|
||||
if cut >= len(body) {
|
||||
cut = len(body)
|
||||
} else {
|
||||
for cut > 0 && !utf8.RuneStart(body[cut]) {
|
||||
cut--
|
||||
}
|
||||
if cut == 0 {
|
||||
// Not valid UTF-8; fall back to the byte budget rather than
|
||||
// looping forever on a prompt we cannot split cleanly.
|
||||
cut = passthroughRawSafeWriteBytes
|
||||
}
|
||||
}
|
||||
var delay time.Duration
|
||||
if len(chunks) > 0 {
|
||||
delay = passthroughRawWriteInterval
|
||||
}
|
||||
chunks = append(chunks, PassthroughStdinChunk{Data: body[:cut], DelayBefore: delay})
|
||||
body = body[cut:]
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write.
|
||||
// The body is framed as a bracketed paste unless the agent cannot accept the
|
||||
// delimiters, so embedded newlines are not treated as premature Enter presses
|
||||
// and a long body is not split across terminal reads the TUI can drop.
|
||||
func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string {
|
||||
if framePassthroughBody(prompt, cfg) {
|
||||
body := stripPasteMarkers(prompt)
|
||||
return bracketedPasteStart + body + bracketedPasteEnd + PassthroughSubmitSequence(cfg)
|
||||
}
|
||||
return prompt + PassthroughSubmitSequence(cfg)
|
||||
}
|
||||
|
||||
// PlanPassthroughStdinChunks plans PTY stdin writes for a passthrough prompt.
|
||||
// When SubmitDelay > 0 (Claude path) the prompt body and submit byte are emitted
|
||||
// as two chunks with DelayBefore set on the submit chunk so it arrives as a
|
||||
// discrete keystroke. Otherwise a single atomic write is returned, preserving
|
||||
// today's behavior for TUIs that handle prompt+submit in one read (Cursor, Codex,
|
||||
// OpenCode).
|
||||
// When SubmitDelay > 0 (Claude path) the submit byte is emitted as its own chunk
|
||||
// with DelayBefore set, so it arrives as a discrete keystroke rather than
|
||||
// trailing bytes of the body. Otherwise the submit sequence rides on the final
|
||||
// body write, preserving the single-write behavior for TUIs that handle
|
||||
// prompt+submit in one read (Cursor, Codex, OpenCode).
|
||||
func PlanPassthroughStdinChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk {
|
||||
chunks := planBodyChunks(prompt, cfg)
|
||||
submit := PassthroughSubmitSequence(cfg)
|
||||
if cfg.SubmitDelay > 0 {
|
||||
body := prompt
|
||||
if !cfg.DisableBracketedPaste && strings.Contains(prompt, "\n") {
|
||||
body = bracketedPasteStart + prompt + bracketedPasteEnd
|
||||
}
|
||||
return []PassthroughStdinChunk{
|
||||
{Data: body},
|
||||
{Data: PassthroughSubmitSequence(cfg), DelayBefore: cfg.SubmitDelay},
|
||||
}
|
||||
return append(chunks, PassthroughStdinChunk{Data: submit, DelayBefore: cfg.SubmitDelay})
|
||||
}
|
||||
return []PassthroughStdinChunk{{Data: BuildPassthroughPayload(prompt, cfg)}}
|
||||
chunks[len(chunks)-1].Data += submit
|
||||
return chunks
|
||||
}
|
||||
|
||||
// PlanPassthroughStdinWrites is the legacy string-only view of PlanPassthroughStdinChunks.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestPlanPassthroughStdinWrites_SingleAtomicWrite(t *testing.T) {
|
||||
@@ -26,22 +28,26 @@ func TestPlanPassthroughStdinWrites_SingleLine(t *testing.T) {
|
||||
}
|
||||
|
||||
// Claude's PassthroughConfig sets SubmitDelay so the submit byte arrives as a
|
||||
// discrete keystroke (defeats Ink's paste-burst detection). Single-line and
|
||||
// multi-line prompts both emit body+submit as two chunks; the submit chunk
|
||||
// carries DelayBefore = SubmitDelay.
|
||||
// discrete keystroke. The body is one chunk and the submit byte another; a
|
||||
// short single-line prompt keeps its exact bytes, a multi-line prompt is framed
|
||||
// as a bracketed paste so the TUI absorbs it as one input.
|
||||
func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) {
|
||||
cfg := NewClaudeACP().PassthroughConfig()
|
||||
if cfg.SubmitDelay <= 0 {
|
||||
t.Fatalf("Claude config must set SubmitDelay > 0 for paste-burst workaround, got %v", cfg.SubmitDelay)
|
||||
}
|
||||
|
||||
for _, prompt := range []string{"hello", "### Review Comments\n\n> fix"} {
|
||||
cases := map[string]string{
|
||||
"hello": "hello",
|
||||
"### Review Comments\n\n> fix": bracketedPasteStart + "### Review Comments\n\n> fix" + bracketedPasteEnd,
|
||||
}
|
||||
for prompt, wantBody := range cases {
|
||||
chunks := PlanPassthroughStdinChunks(prompt, cfg)
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("prompt %q: got %d chunks, want 2 (body, submit): %#v", prompt, len(chunks), chunks)
|
||||
}
|
||||
if chunks[0].Data != prompt {
|
||||
t.Errorf("prompt %q: body chunk = %q, want verbatim prompt", prompt, chunks[0].Data)
|
||||
if chunks[0].Data != wantBody {
|
||||
t.Errorf("prompt %q: body chunk = %q, want %q", prompt, chunks[0].Data, wantBody)
|
||||
}
|
||||
if chunks[0].DelayBefore != 0 {
|
||||
t.Errorf("prompt %q: body chunk DelayBefore = %v, want 0", prompt, chunks[0].DelayBefore)
|
||||
@@ -55,6 +61,86 @@ func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A body larger than one terminal read must travel as one framed write, with the
|
||||
// prompt recoverable byte-for-byte from between the markers.
|
||||
func TestPlanPassthroughStdinChunks_FramesBodyLargerThanOneRead(t *testing.T) {
|
||||
cfg := NewClaudeACP().PassthroughConfig()
|
||||
prompt := strings.Repeat("строка контекста для агента\n", 400)
|
||||
if len(prompt) < 8000 {
|
||||
t.Fatalf("fixture too small to exercise multi-read delivery: %d bytes", len(prompt))
|
||||
}
|
||||
|
||||
chunks := PlanPassthroughStdinChunks(prompt, cfg)
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("got %d chunks, want 2 (framed body, submit): lengths %v", len(chunks), chunkLengths(chunks))
|
||||
}
|
||||
body := chunks[0].Data
|
||||
if !strings.HasPrefix(body, bracketedPasteStart) || !strings.HasSuffix(body, bracketedPasteEnd) {
|
||||
t.Fatalf("body is not bracketed: prefix=%q suffix=%q", body[:6], body[len(body)-6:])
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(body, bracketedPasteStart), bracketedPasteEnd)
|
||||
if inner != prompt {
|
||||
t.Errorf("framed body lost content: got %d bytes, want %d", len(inner), len(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
// A single-line prompt past the raw-safe write size is just as lossy unframed,
|
||||
// so length alone must trigger framing.
|
||||
func TestPlanPassthroughStdinChunks_FramesLongSingleLine(t *testing.T) {
|
||||
cfg := NewClaudeACP().PassthroughConfig()
|
||||
prompt := strings.Repeat("x", passthroughRawSafeWriteBytes+1)
|
||||
|
||||
body := PlanPassthroughStdinChunks(prompt, cfg)[0].Data
|
||||
if !strings.HasPrefix(body, bracketedPasteStart) {
|
||||
t.Fatalf("long single-line body was not framed: %q...", body[:20])
|
||||
}
|
||||
}
|
||||
|
||||
// A marker inside the prompt would end the paste early and let the remainder
|
||||
// reach the TUI as terminal input outside the prompt.
|
||||
func TestPlanPassthroughStdinChunks_NeutralizesFramingTerminators(t *testing.T) {
|
||||
cfg := NewClaudeACP().PassthroughConfig()
|
||||
prompt := "before\n" + bracketedPasteEnd + "rm -rf /\n" + bracketedPasteStart + "after"
|
||||
|
||||
body := PlanPassthroughStdinChunks(prompt, cfg)[0].Data
|
||||
if strings.Count(body, bracketedPasteStart) != 1 || strings.Count(body, bracketedPasteEnd) != 1 {
|
||||
t.Fatalf("markers from the prompt survived framing: %q", body)
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(body, bracketedPasteStart), bracketedPasteEnd)
|
||||
if strings.Contains(inner, "\x1b[") {
|
||||
t.Errorf("escape sequence left inside the framed body: %q", inner)
|
||||
}
|
||||
for _, want := range []string{"before", "rm -rf /", "after"} {
|
||||
if !strings.Contains(inner, want) {
|
||||
t.Errorf("neutralization dropped prompt text %q from %q", want, inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPassthroughStdinChunks_UnframedPreservesFramingMarkers(t *testing.T) {
|
||||
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true}
|
||||
prompt := "literal " + bracketedPasteStart + " marker " + bracketedPasteEnd
|
||||
|
||||
chunks := PlanPassthroughStdinChunks(prompt, cfg)
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("got %d chunks, want one atomic write: %#v", len(chunks), chunks)
|
||||
}
|
||||
if got, want := chunks[0].Data, prompt+"\r"; got != want {
|
||||
t.Errorf("unframed payload = %q, want %q", got, want)
|
||||
}
|
||||
if got := BuildPassthroughPayload(prompt, cfg); got != prompt+"\r" {
|
||||
t.Errorf("BuildPassthroughPayload = %q, want literal markers preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func chunkLengths(chunks []PassthroughStdinChunk) []int {
|
||||
out := make([]int, len(chunks))
|
||||
for i, c := range chunks {
|
||||
out[i] = len(c.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Non-Claude TUIs (SubmitDelay == 0) keep the single-atomic-write semantics so we
|
||||
// don't accidentally regress Cursor/Codex/OpenCode by splitting their submit.
|
||||
func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) {
|
||||
@@ -71,6 +157,72 @@ func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An agent that cannot accept paste framing must still never receive a burst
|
||||
// larger than one terminal read, so its body is written in paced pieces.
|
||||
func TestPlanPassthroughStdinChunks_PacesUnframedBody(t *testing.T) {
|
||||
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true, SubmitDelay: 150 * time.Millisecond}
|
||||
prompt := strings.Repeat("unframed body line\n", 200)
|
||||
|
||||
chunks := PlanPassthroughStdinChunks(prompt, cfg)
|
||||
if len(chunks) < 3 {
|
||||
t.Fatalf("got %d chunks for a %d-byte body, want it paced: %v", len(chunks), len(prompt), chunkLengths(chunks))
|
||||
}
|
||||
|
||||
var body strings.Builder
|
||||
for i, chunk := range chunks[:len(chunks)-1] {
|
||||
if len(chunk.Data) > passthroughRawSafeWriteBytes {
|
||||
t.Errorf("body chunk %d is %d bytes, want at most %d", i, len(chunk.Data), passthroughRawSafeWriteBytes)
|
||||
}
|
||||
wantDelay := passthroughRawWriteInterval
|
||||
if i == 0 {
|
||||
wantDelay = 0
|
||||
}
|
||||
if chunk.DelayBefore != wantDelay {
|
||||
t.Errorf("body chunk %d DelayBefore = %v, want %v", i, chunk.DelayBefore, wantDelay)
|
||||
}
|
||||
body.WriteString(chunk.Data)
|
||||
}
|
||||
if body.String() != prompt {
|
||||
t.Errorf("paced body lost content: got %d bytes, want %d", body.Len(), len(prompt))
|
||||
}
|
||||
|
||||
submit := chunks[len(chunks)-1]
|
||||
if submit.Data != "\r" || submit.DelayBefore != cfg.SubmitDelay {
|
||||
t.Errorf("submit chunk = %q after %v, want %q after %v", submit.Data, submit.DelayBefore, "\r", cfg.SubmitDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// Splitting by bytes would corrupt non-ASCII prompts.
|
||||
func TestPlanPassthroughStdinChunks_UnframedSplitsOnRuneBoundaries(t *testing.T) {
|
||||
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true, SubmitDelay: 150 * time.Millisecond}
|
||||
prompt := strings.Repeat("сообщение агенту ", 200)
|
||||
|
||||
for i, chunk := range PlanPassthroughStdinChunks(prompt, cfg) {
|
||||
if !utf8.ValidString(chunk.Data) {
|
||||
t.Fatalf("chunk %d ends inside a multi-byte character: %q", i, chunk.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A TUI with no submit delay still gets a paced body; its submit sequence rides
|
||||
// on the final write so prompt and submit stay one read for short prompts.
|
||||
func TestPlanPassthroughStdinChunks_PacesUnframedBodyWithoutSubmitDelay(t *testing.T) {
|
||||
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true}
|
||||
prompt := strings.Repeat("x", passthroughRawSafeWriteBytes*3)
|
||||
|
||||
chunks := PlanPassthroughStdinChunks(prompt, cfg)
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("got %d chunks, want 3 paced writes: %v", len(chunks), chunkLengths(chunks))
|
||||
}
|
||||
var joined strings.Builder
|
||||
for _, chunk := range chunks {
|
||||
joined.WriteString(chunk.Data)
|
||||
}
|
||||
if joined.String() != prompt+"\r" {
|
||||
t.Errorf("paced atomic path lost content: got %d bytes, want %d", joined.Len(), len(prompt)+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: a custom config with SubmitDelay set but no Claude-specific flags
|
||||
// also splits — the field is the lever, not the Claude struct identity.
|
||||
func TestPlanPassthroughStdinChunks_SubmitDelayDrivesSplit(t *testing.T) {
|
||||
|
||||
@@ -36,6 +36,9 @@ type TUIAgentConfig struct {
|
||||
// an MCP client at all. Resolved from the user's stored strategy key; see
|
||||
// mcpconfig.StrategyByKey for why it is chosen rather than inferred.
|
||||
MCPStrategy mcpconfig.PassthroughMCPStrategy
|
||||
// DisableBracketedPaste selects the paced unframed delivery path for a
|
||||
// terminal that does not accept bracketed-paste delimiters.
|
||||
DisableBracketedPaste bool
|
||||
}
|
||||
|
||||
// TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools.
|
||||
@@ -74,24 +77,23 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent {
|
||||
a := &TUIAgent{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: cfg.Desc,
|
||||
PassthroughCmd: NewCommand(cmdArgs...),
|
||||
ModelFlag: cfg.ModelFlag,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
BufferMaxBytes: cfg.BufferMax,
|
||||
WaitForTerminal: cfg.WaitForTerm,
|
||||
MCPStrategy: cfg.MCPStrategy,
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: cfg.Desc,
|
||||
PassthroughCmd: NewCommand(cmdArgs...),
|
||||
ModelFlag: cfg.ModelFlag,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
BufferMaxBytes: cfg.BufferMax,
|
||||
WaitForTerminal: cfg.WaitForTerm,
|
||||
MCPStrategy: cfg.MCPStrategy,
|
||||
DisableBracketedPaste: cfg.DisableBracketedPaste,
|
||||
// Ink-based TUIs (Claude Code and similar) coalesce multi-byte
|
||||
// stdin reads into a paste burst, absorbing a trailing "\r" into
|
||||
// the pasted content instead of dispatching Enter, and enable
|
||||
// bracketed-paste mode so ESC[200~…ESC[201~ delimiters break
|
||||
// input. Send prompt bytes verbatim and split the submit byte
|
||||
// into a discrete keystroke so programmatic PTY prompts submit.
|
||||
// Matches the built-in Claude passthrough agent.
|
||||
DisableBracketedPaste: true,
|
||||
SubmitDelay: 150 * time.Millisecond,
|
||||
// the pasted content instead of dispatching Enter. The submit
|
||||
// byte is therefore a separate delayed keystroke, and the body
|
||||
// travels as a bracketed paste so it arrives whole at any
|
||||
// length. Matches the built-in Claude passthrough agent.
|
||||
SubmitDelay: 150 * time.Millisecond,
|
||||
},
|
||||
},
|
||||
cfg: cfg,
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
)
|
||||
|
||||
// Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude
|
||||
// Code. Ink coalesces multi-byte stdin reads into a paste burst and absorbs a
|
||||
// trailing "\r" into the pasted content instead of dispatching Enter, and it
|
||||
// enables bracketed-paste mode so ESC[200~…ESC[201~ delimiters break input.
|
||||
// The built-in Claude passthrough agent works around both; custom TUI agents
|
||||
// must inherit the same defaults or programmatic PTY prompts (peer messaging,
|
||||
// queued-message drain, workflow auto-start) land in the input box unsubmitted.
|
||||
// Code. Ink absorbs a trailing "\r" into a pasted burst instead of dispatching
|
||||
// Enter, so the submit byte needs its own delayed write. Bracketed-paste
|
||||
// framing stays on: without it a body larger than one terminal read reaches the
|
||||
// TUI as a burst it silently drops whole reads from. Custom TUI agents must
|
||||
// inherit both defaults or programmatic PTY prompts (peer messaging,
|
||||
// queued-message drain, workflow auto-start) arrive truncated or unsubmitted.
|
||||
func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
|
||||
a := NewTUIAgent(TUIAgentConfig{
|
||||
AgentID: "custom-tui",
|
||||
@@ -24,8 +24,8 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
|
||||
})
|
||||
|
||||
pt := a.PassthroughConfig()
|
||||
if !pt.DisableBracketedPaste {
|
||||
t.Error("DisableBracketedPaste = false, want true (send prompt bytes verbatim; Ink breaks on bracketed-paste delimiters)")
|
||||
if pt.DisableBracketedPaste {
|
||||
t.Error("DisableBracketedPaste = true, want false (framing is what carries a body past one terminal read)")
|
||||
}
|
||||
if pt.SubmitDelay != 150*time.Millisecond {
|
||||
t.Errorf("SubmitDelay = %v, want 150ms (split submit byte into a discrete keystroke so Ink does not absorb it into a paste burst)", pt.SubmitDelay)
|
||||
|
||||
@@ -37,6 +37,9 @@ type CustomTUIAgentSpec struct {
|
||||
// Protocol selects the runtime kandev drives the command with. Empty means
|
||||
// terminal passthrough.
|
||||
Protocol CustomAgentProtocol
|
||||
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
|
||||
// that do not accept bracketed-paste delimiters.
|
||||
DisableBracketedPaste bool
|
||||
}
|
||||
|
||||
// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no
|
||||
@@ -89,14 +92,15 @@ func buildCustomAgent(spec CustomTUIAgentSpec) (agents.Agent, error) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
|
||||
}
|
||||
return agents.NewTUIAgent(agents.TUIAgentConfig{
|
||||
AgentID: spec.Slug,
|
||||
AgentName: spec.Slug,
|
||||
Command: binary,
|
||||
Desc: spec.Description,
|
||||
Display: spec.DisplayName,
|
||||
WaitForTerm: true,
|
||||
CommandArgs: args,
|
||||
MCPStrategy: strategy,
|
||||
AgentID: spec.Slug,
|
||||
AgentName: spec.Slug,
|
||||
Command: binary,
|
||||
Desc: spec.Description,
|
||||
Display: spec.DisplayName,
|
||||
WaitForTerm: true,
|
||||
CommandArgs: args,
|
||||
MCPStrategy: strategy,
|
||||
DisableBracketedPaste: spec.DisableBracketedPaste,
|
||||
}), nil
|
||||
case CustomAgentProtocolACP:
|
||||
if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone {
|
||||
|
||||
@@ -109,6 +109,33 @@ func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustomTUIAgent_DisableBracketedPaste(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
|
||||
Slug: "raw-tui",
|
||||
DisplayName: "Raw TUI",
|
||||
Command: "raw-tui",
|
||||
DisableBracketedPaste: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ag, ok := reg.Get("raw-tui")
|
||||
if !ok {
|
||||
t.Fatal("expected agent to be registered")
|
||||
}
|
||||
pt, ok := ag.(agents.PassthroughAgent)
|
||||
if !ok {
|
||||
t.Fatal("custom terminal agent does not implement PassthroughAgent")
|
||||
}
|
||||
if !pt.PassthroughConfig().DisableBracketedPaste {
|
||||
t.Error("DisableBracketedPaste = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
@@ -1721,8 +1721,12 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
|
||||
zap.Error(err))
|
||||
}
|
||||
for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) {
|
||||
if chunk.DelayBefore > 0 {
|
||||
time.Sleep(chunk.DelayBefore)
|
||||
if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil {
|
||||
m.logger.Warn("autoInjectInitialPrompt delay interrupted",
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("process_id", processID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := runner.WriteStdin(processID, chunk.Data); err != nil {
|
||||
m.logger.Warn("autoInjectInitialPrompt write failed",
|
||||
@@ -1738,6 +1742,20 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
|
||||
zap.Int("description_len", len(description)))
|
||||
}
|
||||
|
||||
func waitPassthroughChunkDelay(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePassthroughConfig returns the PassthroughConfig for a session's agent.
|
||||
// Used by callers outside this package (e.g. orchestrator) that need the submit
|
||||
// sequence to write to PTY stdin.
|
||||
|
||||
@@ -38,6 +38,9 @@ type CreateCustomTUIAgentRequest struct {
|
||||
// Protocol is the runtime kandev drives the command with
|
||||
// (registry.CustomAgentProtocol*). Empty means terminal passthrough.
|
||||
Protocol string
|
||||
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
|
||||
// that do not accept bracketed-paste delimiters.
|
||||
DisableBracketedPaste bool
|
||||
}
|
||||
|
||||
// validateCustomAgentProtocol rejects a protocol/strategy pair the registry
|
||||
@@ -92,14 +95,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
||||
|
||||
// Register in the in-memory registry
|
||||
if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
|
||||
Slug: slug,
|
||||
DisplayName: req.DisplayName,
|
||||
Command: req.Command,
|
||||
Description: req.Description,
|
||||
Model: req.Model,
|
||||
CommandArgs: req.CommandArgs,
|
||||
MCPStrategyKey: req.MCPStrategy,
|
||||
Protocol: protocol,
|
||||
Slug: slug,
|
||||
DisplayName: req.DisplayName,
|
||||
Command: req.Command,
|
||||
Description: req.Description,
|
||||
Model: req.Model,
|
||||
CommandArgs: req.CommandArgs,
|
||||
MCPStrategyKey: req.MCPStrategy,
|
||||
Protocol: protocol,
|
||||
DisableBracketedPaste: req.DisableBracketedPaste,
|
||||
}); regErr != nil {
|
||||
return nil, fmt.Errorf("failed to register agent: %w", regErr)
|
||||
}
|
||||
@@ -107,14 +111,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
||||
// Persist to DB
|
||||
acp := protocol == registry.CustomAgentProtocolACP
|
||||
tuiConfig := &models.TUIConfigJSON{
|
||||
Command: req.Command,
|
||||
DisplayName: req.DisplayName,
|
||||
Model: req.Model,
|
||||
Description: req.Description,
|
||||
CommandArgs: req.CommandArgs,
|
||||
WaitForTerminal: true,
|
||||
MCPStrategy: req.MCPStrategy,
|
||||
Protocol: req.Protocol,
|
||||
Command: req.Command,
|
||||
DisplayName: req.DisplayName,
|
||||
Model: req.Model,
|
||||
Description: req.Description,
|
||||
CommandArgs: req.CommandArgs,
|
||||
WaitForTerminal: true,
|
||||
MCPStrategy: req.MCPStrategy,
|
||||
Protocol: req.Protocol,
|
||||
DisableBracketedPaste: req.DisableBracketedPaste,
|
||||
}
|
||||
agent := &models.Agent{
|
||||
Name: slug,
|
||||
@@ -251,14 +256,15 @@ func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
|
||||
// silently miss the other.
|
||||
func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec {
|
||||
return registry.CustomTUIAgentSpec{
|
||||
Slug: name,
|
||||
DisplayName: cfg.DisplayName,
|
||||
Command: cfg.Command,
|
||||
Description: cfg.Description,
|
||||
Model: cfg.Model,
|
||||
CommandArgs: cfg.CommandArgs,
|
||||
MCPStrategyKey: cfg.MCPStrategy,
|
||||
Protocol: registry.CustomAgentProtocol(cfg.Protocol),
|
||||
Slug: name,
|
||||
DisplayName: cfg.DisplayName,
|
||||
Command: cfg.Command,
|
||||
Description: cfg.Description,
|
||||
Model: cfg.Model,
|
||||
CommandArgs: cfg.CommandArgs,
|
||||
MCPStrategyKey: cfg.MCPStrategy,
|
||||
Protocol: registry.CustomAgentProtocol(cfg.Protocol),
|
||||
DisableBracketedPaste: cfg.DisableBracketedPaste,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,34 @@ func TestCreateCustomTUIAgent_CommandArgsPersisted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCustomTUIAgent_DisableBracketedPasteReachesRuntimeAndStorage(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
|
||||
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Raw TUI",
|
||||
Command: "raw-tui",
|
||||
DisableBracketedPaste: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
ag, ok := c.agentRegistry.Get("raw-tui")
|
||||
if !ok {
|
||||
t.Fatal("custom agent was not registered")
|
||||
}
|
||||
pt, ok := ag.(agents.PassthroughAgent)
|
||||
if !ok {
|
||||
t.Fatal("custom agent is not a passthrough agent")
|
||||
}
|
||||
if !pt.PassthroughConfig().DisableBracketedPaste {
|
||||
t.Error("runtime DisableBracketedPaste = false, want true")
|
||||
}
|
||||
if !st.byName["raw-tui"].TUIConfig.DisableBracketedPaste {
|
||||
t.Error("stored DisableBracketedPaste = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCustomTUIAgent_NoCommandArgs keeps the existing behaviour intact
|
||||
// when the caller omits the field.
|
||||
func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
|
||||
|
||||
@@ -1218,14 +1218,15 @@ func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentPro
|
||||
}
|
||||
if agent.TUIConfig != nil {
|
||||
result.TUIConfig = &dto.TUIConfigDTO{
|
||||
Command: agent.TUIConfig.Command,
|
||||
DisplayName: agent.TUIConfig.DisplayName,
|
||||
Model: agent.TUIConfig.Model,
|
||||
Description: agent.TUIConfig.Description,
|
||||
CommandArgs: agent.TUIConfig.CommandArgs,
|
||||
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
||||
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
||||
Protocol: agent.TUIConfig.Protocol,
|
||||
Command: agent.TUIConfig.Command,
|
||||
DisplayName: agent.TUIConfig.DisplayName,
|
||||
Model: agent.TUIConfig.Model,
|
||||
Description: agent.TUIConfig.Description,
|
||||
CommandArgs: agent.TUIConfig.CommandArgs,
|
||||
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
||||
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
||||
Protocol: agent.TUIConfig.Protocol,
|
||||
DisableBracketedPaste: agent.TUIConfig.DisableBracketedPaste,
|
||||
}
|
||||
}
|
||||
if c.agentRegistry != nil {
|
||||
|
||||
@@ -146,6 +146,9 @@ type TUIConfigDTO struct {
|
||||
// Protocol is the runtime kandev drives the command with ("" = terminal
|
||||
// passthrough, "acp" = ACP on stdin/stdout).
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
|
||||
// that do not accept bracketed-paste delimiters.
|
||||
DisableBracketedPaste bool `json:"disable_bracketed_paste,omitempty"`
|
||||
}
|
||||
|
||||
type AgentDTO struct {
|
||||
|
||||
@@ -26,17 +26,21 @@ type createCustomTUIAgentRequest struct {
|
||||
// Protocol is the runtime kandev drives the command with: empty (the
|
||||
// default) for terminal passthrough, "acp" for ACP on stdin/stdout.
|
||||
Protocol string `json:"protocol"`
|
||||
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
|
||||
// that do not accept bracketed-paste delimiters.
|
||||
DisableBracketedPaste bool `json:"disable_bracketed_paste"`
|
||||
}
|
||||
|
||||
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
|
||||
return controller.CreateCustomTUIAgentRequest{
|
||||
DisplayName: r.DisplayName,
|
||||
Model: r.Model,
|
||||
Command: r.Command,
|
||||
Description: r.Description,
|
||||
CommandArgs: r.CommandArgs,
|
||||
MCPStrategy: r.MCPStrategy,
|
||||
Protocol: r.Protocol,
|
||||
DisplayName: r.DisplayName,
|
||||
Model: r.Model,
|
||||
Command: r.Command,
|
||||
Description: r.Description,
|
||||
CommandArgs: r.CommandArgs,
|
||||
MCPStrategy: r.MCPStrategy,
|
||||
Protocol: r.Protocol,
|
||||
DisableBracketedPaste: r.DisableBracketedPaste,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) {
|
||||
payload := `{
|
||||
"display_name": "Spaced Args",
|
||||
"command": "my-cli",
|
||||
"command_args": ["--system-prompt", "you are a helpful agent"]
|
||||
"command_args": ["--system-prompt", "you are a helpful agent"],
|
||||
"disable_bracketed_paste": true
|
||||
}`
|
||||
|
||||
var body createCustomTUIAgentRequest
|
||||
@@ -30,6 +31,9 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) {
|
||||
if req.DisplayName != "Spaced Args" || req.Command != "my-cli" {
|
||||
t.Errorf("unexpected mapping: %+v", req)
|
||||
}
|
||||
if !req.DisableBracketedPaste {
|
||||
t.Error("DisableBracketedPaste = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCustomTUIAgentRequest_OmittedCommandArgs keeps the field optional.
|
||||
|
||||
@@ -50,6 +50,9 @@ type TUIConfigJSON struct {
|
||||
// (registry.CustomAgentProtocol*). Empty — the value every row written
|
||||
// before this field existed decodes to — means terminal passthrough.
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
|
||||
// that do not accept bracketed-paste delimiters.
|
||||
DisableBracketedPaste bool `json:"disable_bracketed_paste,omitempty"`
|
||||
}
|
||||
|
||||
type AgentProfile struct {
|
||||
|
||||
@@ -2516,6 +2516,25 @@ func TestDeliverPassthroughPrompt(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refuses the prompt when config resolution fails", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
configErr := errors.New("agent definition is unavailable")
|
||||
agentMgr := &mockAgentManager{
|
||||
isPassthrough: true,
|
||||
passthroughConfigErr: configErr,
|
||||
}
|
||||
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
|
||||
|
||||
err := svc.writePassthroughPrompt(context.Background(), "s1", strings.Repeat("long prompt ", 100))
|
||||
if !errors.Is(err, configErr) {
|
||||
t.Fatalf("writePassthroughPrompt error = %v, want config error", err)
|
||||
}
|
||||
if got := len(agentMgr.passthroughStdinCalls); got != 0 {
|
||||
t.Fatalf("stdin calls after config failure = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancellation interrupts submit delay", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
@@ -5592,8 +5592,8 @@ type passthroughRunningPreparer interface {
|
||||
// deliverPassthroughPrompt writes a prompt to PTY stdin and marks the session as running.
|
||||
// Uses the per-agent PlanPassthroughStdinChunks so Claude's inter-chunk SubmitDelay is
|
||||
// honored here too (queued / workflow-auto-start path); other agents stay on the single
|
||||
// atomic write. Falls back to the simple "\r" append if config resolution fails so a
|
||||
// transient lookup error never silently swallows the prompt.
|
||||
// atomic write. Config resolution is required: an unknown agent contract must not fall
|
||||
// back to one unframed write, because a long prompt can be silently truncated.
|
||||
//
|
||||
// Callers that already hold the per-session cancellation guard must use
|
||||
// PreparePassthroughRunning + writePassthroughPrompt instead (see handleAgentReady); this
|
||||
@@ -5618,15 +5618,10 @@ func (s *Service) deliverPassthroughPrompt(ctx context.Context, sessionID, conte
|
||||
func (s *Service) writePassthroughPrompt(ctx context.Context, sessionID, content string) error {
|
||||
pt, cfgErr := s.agentManager.ResolvePassthroughConfig(ctx, sessionID)
|
||||
if cfgErr != nil {
|
||||
s.logger.Warn("failed to resolve passthrough config, falling back to \\r submit",
|
||||
s.logger.Warn("failed to resolve passthrough config; refusing unsafe prompt write",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(cfgErr))
|
||||
}
|
||||
if cfgErr != nil {
|
||||
if err := s.agentManager.WritePassthroughStdin(ctx, sessionID, content+"\r"); err != nil {
|
||||
return fmt.Errorf("write to passthrough stdin: %w", err)
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("resolve passthrough config: %w", cfgErr)
|
||||
}
|
||||
for _, chunk := range agents.PlanPassthroughStdinChunks(content, pt) {
|
||||
if chunk.DelayBefore > 0 {
|
||||
|
||||
@@ -724,8 +724,8 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session
|
||||
zap.Error(err))
|
||||
}
|
||||
for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) {
|
||||
if chunk.DelayBefore > 0 {
|
||||
time.Sleep(chunk.DelayBefore)
|
||||
if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.agentManager.WritePassthroughStdin(ctx, sessionID, chunk.Data); err != nil {
|
||||
return nil, fmt.Errorf("failed to write to passthrough stdin: %w", err)
|
||||
@@ -734,6 +734,20 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session
|
||||
return &PromptResult{StopReason: stopReasonPassthrough}, nil
|
||||
}
|
||||
|
||||
func waitPassthroughChunkDelay(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) {
|
||||
if len(attachments) == 0 {
|
||||
return prompt, nil
|
||||
|
||||
@@ -588,6 +588,37 @@ func TestExecutor_Prompt_PassthroughSubmitDelaySplitsWrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Prompt_PassthroughCancellationStopsDelayedSubmit(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
agentManager := &mockAgentManager{
|
||||
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
|
||||
resolvePassthroughConfigFunc: func(_ context.Context, _ string) (agents.PassthroughConfig, error) {
|
||||
return agents.PassthroughConfig{
|
||||
Supported: true,
|
||||
SubmitSequence: "\r",
|
||||
DisableBracketedPaste: true,
|
||||
SubmitDelay: time.Second,
|
||||
}, nil
|
||||
},
|
||||
writePassthroughStdinFunc: func(_ context.Context, _ string, data string) error {
|
||||
if data == "hello agent" {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
|
||||
exec := newTestExecutor(t, agentManager, repo)
|
||||
|
||||
if _, err := exec.Prompt(ctx, "task-1", "sess-1", "hello agent", nil, false); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Prompt error = %v, want context cancellation", err)
|
||||
}
|
||||
if got := len(agentManager.writePassthroughStdinCalls); got != 1 {
|
||||
t.Fatalf("stdin calls after cancellation = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
agentManager := &mockAgentManager{
|
||||
|
||||
@@ -347,6 +347,7 @@ export async function createCustomTUIAgent(
|
||||
description?: string;
|
||||
mcp_strategy?: string;
|
||||
protocol?: string;
|
||||
disable_bracketed_paste?: boolean;
|
||||
},
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<Agent> {
|
||||
|
||||
@@ -47,6 +47,8 @@ export type TUIConfig = {
|
||||
* calls, models, and modes.
|
||||
*/
|
||||
protocol?: CustomAgentProtocol;
|
||||
/** Use paced unframed writes for TUIs that reject bracketed-paste markers. */
|
||||
disable_bracketed_paste?: boolean;
|
||||
};
|
||||
|
||||
/** One selectable MCP injection mechanism, served by the backend. */
|
||||
|
||||
@@ -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.
|
||||
+123
@@ -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.
|
||||
+115
@@ -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).
|
||||
@@ -76,7 +76,7 @@ When a passthrough session starts for a task that has a description:
|
||||
|
||||
No per-agent pattern matchers. The existing idle window is the only readiness signal. If an agent's CLI is unusual enough that an idle window misfires (writes a banner, then waits 5 seconds, then prompts), we make the idle window per-agent-configurable in `PassthroughConfig` (already exists as `IdleTimeout`). No new detection machinery.
|
||||
|
||||
For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata, sets `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route.
|
||||
For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata and uses a 150 ms `SubmitDelay`. The prompt body uses bracketed-paste framing, then the submit carriage return is sent as a separate delayed write. This keeps the submit byte out of the Ink paste burst. Custom terminal agents can set `DisableBracketedPaste: true` when they do not accept bracketed-paste delimiters; Kandev then uses bounded, paced unframed writes. The pacing is an empirical fallback because PTY writes do not provide a receipt from the TUI. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route.
|
||||
|
||||
### Follow-up prompts via PTY stdin
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
status: draft
|
||||
system: cli
|
||||
created: 2026-09-22
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Passthrough Prompt Delivery Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
In CLI passthrough mode Kandev runs a vendor CLI inside a PTY and types prompts
|
||||
into it, because that CLI exposes no structured prompt channel. Every other
|
||||
delivery surface hands the agent a framed message; passthrough hands it
|
||||
keystrokes, so the prompt is only as reliable as the terminal path that carries
|
||||
it. A host terminal delivers input to the reader in fixed-size reads, and a TUI
|
||||
that cannot absorb a burst that large drops whole reads without reporting an
|
||||
error. The operator sees a complete message in Kandev, the agent answers a
|
||||
truncated one, and nothing in either surface marks the difference.
|
||||
|
||||
The CLI system owns this contract because it owns how Kandev drives a vendor CLI
|
||||
through a PTY and the compatibility behavior that path needs. What is delivered
|
||||
and when it is delivered stay with their own owners: the tasks system owns the
|
||||
message API and its delivery modes, and the platform system owns delivery into a
|
||||
generating turn.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **CLI passthrough session:** An agent session whose profile sets
|
||||
`cli_passthrough`, where Kandev runs the vendor CLI as a PTY child and mirrors
|
||||
that terminal to the operator.
|
||||
- **Prompt body:** The prompt text Kandev delivers, excluding the submit
|
||||
keystroke.
|
||||
- **Submit keystroke:** The byte sequence that makes the receiving TUI start a
|
||||
turn for the delivered body.
|
||||
- **Bracketed paste:** The terminal convention in which `ESC[200~` and `ESC[201~`
|
||||
delimit pasted text so a TUI treats the whole burst as one paste rather than a
|
||||
stream of keystrokes.
|
||||
- **Single-read capacity:** The number of bytes a host terminal hands its reader
|
||||
per read. It is a host constant, not a Kandev setting, and is smaller than a
|
||||
typical prompt.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001: Passthrough Prompt Delivery Integrity
|
||||
|
||||
**Intent:** A prompt that Kandev accepts for a passthrough session must reach
|
||||
the agent as the same text the operator or calling agent sent. Silent partial
|
||||
delivery is worse than a failed delivery: the agent acts on a fragment, the
|
||||
answer looks coherent, and the loss is invisible in every Kandev surface.
|
||||
|
||||
**User story:** As an operator running an agent in CLI passthrough mode, I want
|
||||
every prompt to arrive whole, so that the agent answers what was actually sent.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1:** When Kandev delivers a prompt to
|
||||
a CLI passthrough session, the receiving agent shall receive the prompt body
|
||||
in full and unmodified, at any body length.
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.2:** When a prompt body exceeds the
|
||||
host's single-read capacity, delivery shall preserve body order and shall
|
||||
present the body to the receiving TUI as one continuous input rather than as
|
||||
unrelated input events it has to rejoin.
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.3:** When the prompt body has been
|
||||
delivered, the submit keystroke shall arrive as a discrete input event, so the
|
||||
receiving TUI starts exactly one turn for that body.
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.4:** When the receiving agent is
|
||||
configured as unable to accept bracketed-paste framing, delivery shall keep
|
||||
AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1 true by pacing the body within the
|
||||
host's single-read capacity instead of writing one oversized burst.
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.5:** When the prompt body contains
|
||||
byte sequences that would end the delivery framing early, delivery shall
|
||||
neutralize those sequences so no part of the body reaches the TUI as terminal
|
||||
input outside the prompt.
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.6:** When a prompt originates from the
|
||||
initial task description, an operator composer message, a workflow step, or an
|
||||
agent-to-agent message, delivery integrity shall be identical, because all
|
||||
passthrough prompt sources share one delivery contract.
|
||||
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.7:** When delivery of a body cannot
|
||||
complete, Kandev shall report the failure to the caller rather than leaving a
|
||||
partially typed prompt as a successful delivery.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Prompt transport for non-passthrough sessions, which uses the structured agent
|
||||
protocol and not a terminal.
|
||||
- Operator keystrokes typed directly into the mirrored terminal view, which the
|
||||
terminal input path owns.
|
||||
- Which prompt is delivered, its wrapping, its queue position, and its delivery
|
||||
mode, owned by the tasks system.
|
||||
- Delivery into an already generating turn, owned by the platform system's
|
||||
mid-turn steering contract.
|
||||
- How the receiving agent interprets or answers the prompt once it has it.
|
||||
- Message storage and rendering in Kandev surfaces, owned by the tasks and UI
|
||||
systems.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
status: draft
|
||||
system: cli
|
||||
requirements:
|
||||
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
|
||||
---
|
||||
|
||||
# Passthrough Prompt Delivery System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design owns how a prompt that is already selected for a CLI passthrough
|
||||
session becomes bytes on that session's PTY. It owns the framing of the body,
|
||||
the size and pacing of the writes, and the separation of the submit keystroke.
|
||||
|
||||
It does not own the prompt text, its system wrapper, its queue position, or its
|
||||
delivery mode; those arrive from the tasks system. It does not own PTY process
|
||||
startup, terminal mirroring, or operator keystrokes, which belong to the
|
||||
interactive process runner. It consumes the per-agent passthrough capability
|
||||
record but does not own the agent catalog.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001` | [Components and responsibilities](#components-and-responsibilities), [Data and contracts](#data-and-contracts), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery), [Security](#security) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- **Passthrough capability record.** Per-agent configuration that states the
|
||||
submit sequence, the submit delay, and whether the agent accepts
|
||||
bracketed-paste framing. It is the only input that varies delivery behavior
|
||||
between agents; delivery code never branches on an agent identity.
|
||||
- **Stdin chunk planner.** The single function that converts a prompt and a
|
||||
capability record into an ordered list of PTY writes. It is pure: no PTY, no
|
||||
clock, no I/O, so every framing and sizing rule is unit-testable. It is the
|
||||
only place that knows about framing markers and the host read budget.
|
||||
- **Delivery callers.** The initial-prompt injector, the workflow step prompt
|
||||
handler, and the session prompt path each obtain the planned chunks and write
|
||||
them in order, honoring each chunk's pre-write delay. They add no framing of
|
||||
their own.
|
||||
- **PTY writer.** The lifecycle manager's passthrough stdin call and the
|
||||
interactive runner behind it write one chunk per call to the PTY master and
|
||||
surface write errors to the caller.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
- **Planned chunk.** An ordered pair of the bytes to write and the delay to
|
||||
apply before writing them. The first chunk carries no delay. This type already
|
||||
exists and is unchanged.
|
||||
- **Framing markers.** The bracketed-paste start and end sequences. They are
|
||||
applied to the body only, never to the submit keystroke.
|
||||
- **Raw-safe write size.** A byte budget for one write on the unframed path,
|
||||
chosen below the smallest host single-read capacity Kandev supports, so a
|
||||
receiving TUI never has to absorb more than one read worth of unframed input
|
||||
at a time. Splits fall on rune boundaries so no write ends mid-character.
|
||||
- **Inter-chunk delay.** The pause applied before each continuation write on the
|
||||
unframed path, sized so the receiving TUI drains one read before the next
|
||||
arrives.
|
||||
- **Submit delay.** The existing per-agent pause before the submit keystroke,
|
||||
which keeps that keystroke a discrete input event rather than trailing bytes
|
||||
of the body.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. A caller resolves the session's capability record and asks the planner for
|
||||
the chunks of a prompt.
|
||||
2. If framing is selected, the planner neutralizes framing terminators inside the body.
|
||||
3. When the agent accepts bracketed-paste framing and the body is multi-line or
|
||||
larger than the raw-safe write size, the planner emits the framed body as one
|
||||
chunk. Small single-line bodies stay unframed, preserving today's bytes.
|
||||
4. When the agent does not accept bracketed-paste framing, the planner emits the
|
||||
body as consecutive chunks no larger than the raw-safe write size, each
|
||||
continuation chunk carrying the inter-chunk delay.
|
||||
5. When `SubmitDelay > 0`, the planner appends the submit keystroke as its own
|
||||
chunk carrying the submit delay. When `SubmitDelay == 0`, it appends the
|
||||
submit sequence to the final body chunk.
|
||||
6. The caller writes each chunk in order, waiting for the chunk's delay with the
|
||||
caller context first.
|
||||
|
||||
Ordering is total: the PTY is a single stream, and the caller writes
|
||||
sequentially, so the body writes and submit keystroke are sent in order. The
|
||||
unframed pacing reduces burst loss but cannot confirm what the TUI consumed.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
A failed write aborts the remaining chunks for that prompt. The session prompt
|
||||
path returns the failure to its caller, which reverts session state and surfaces
|
||||
the error, so a half-typed prompt is never reported as delivered. The initial
|
||||
prompt injector logs the failure and stops rather than retrying, because a retry
|
||||
would duplicate the already-written prefix in the agent's input.
|
||||
|
||||
Delivery does not read the terminal back, so it cannot confirm what the TUI
|
||||
absorbed. Correctness therefore rests on framing and sizing rather than on
|
||||
acknowledgement. The 30 ms unframed interval is an empirical pacing policy, not
|
||||
a universal lossless-delivery guarantee. Any change to these rules needs
|
||||
evidence from a real TUI.
|
||||
|
||||
## Security
|
||||
|
||||
Prompt bodies are untrusted for terminal framing. A body that contains a framing
|
||||
terminator would end the paste early and let the remainder reach the TUI as
|
||||
terminal input outside the prompt, which in a CLI agent means unintended
|
||||
commands. The planner therefore neutralizes framing terminators in the body
|
||||
before wrapping it. It performs no other rewriting: the body is agent-visible
|
||||
content and must otherwise arrive byte-for-byte.
|
||||
|
||||
## Observability
|
||||
|
||||
Delivery callers log the byte length of what they write: the initial prompt
|
||||
injector logs the injected description length, and the session prompt path logs
|
||||
the prompt length at debug level. The planner itself logs nothing, because it is
|
||||
pure.
|
||||
|
||||
The authoritative delivery check is comparison, not logging: the stored message
|
||||
text against the receiving agent's own transcript of what it was sent. That
|
||||
comparison is what exposed the original defect, a loss of exactly N x 1022
|
||||
bytes from the start of every long body. Per-delivery logging of chunk count and
|
||||
framing is not implemented; it would let an operator distinguish a mis-planned
|
||||
delivery from one the TUI mishandled without a transcript.
|
||||
Reference in New Issue
Block a user