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. */
|
||||
|
||||
Reference in New Issue
Block a user