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
+10 -8
View File
@@ -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) {
+18 -16
View File
@@ -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{
+1
View File
@@ -347,6 +347,7 @@ export async function createCustomTUIAgent(
description?: string;
mcp_strategy?: string;
protocol?: string;
disable_bracketed_paste?: boolean;
},
options?: ApiRequestOptions,
): Promise<Agent> {
+2
View File
@@ -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. */