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.
|
// and when routing chat-compose messages to the PTY. "\r" for most TUIs.
|
||||||
// Empty inherits DefaultPassthroughSubmitSequence at PTY write sites.
|
// Empty inherits DefaultPassthroughSubmitSequence at PTY write sites.
|
||||||
SubmitSequence string
|
SubmitSequence string
|
||||||
// DisableBracketedPaste sends prompt bytes verbatim (plus SubmitSequence).
|
// DisableBracketedPaste sends the prompt body without ESC[200~…ESC[201~
|
||||||
// Claude Code enables bracketed-paste *mode* (?2004h) in its Ink TUI; injecting
|
// delimiters. The planner then paces the body in writes that each fit within
|
||||||
// ESC[200~…ESC[201~ delimiters breaks input (nothing appears in the prompt).
|
// 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
|
DisableBracketedPaste bool
|
||||||
// SubmitDelay is the wait inserted before each non-first chunk when writing the
|
// SubmitDelay is the wait inserted before the separate submit chunk when
|
||||||
// prompt+submit sequence to PTY stdin. Ink-based TUIs (Claude Code) detect a
|
// 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
|
// "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.
|
// 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
|
// Writing the submit byte on its own after a small delay makes it arrive as
|
||||||
// submit to arrive as a discrete keystroke. 0 disables (other TUIs handle one
|
// a discrete keystroke. 0 appends the submit sequence to the final body
|
||||||
// atomic write fine).
|
// write (other TUIs handle prompt and submit in one read).
|
||||||
SubmitDelay time.Duration
|
SubmitDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,24 +52,25 @@ func NewClaudeACP() *ClaudeACP {
|
|||||||
StandardPassthrough: StandardPassthrough{
|
StandardPassthrough: StandardPassthrough{
|
||||||
PermSettings: claudeACPPermSettings,
|
PermSettings: claudeACPPermSettings,
|
||||||
Cfg: PassthroughConfig{
|
Cfg: PassthroughConfig{
|
||||||
Supported: true,
|
Supported: true,
|
||||||
Label: "CLI Passthrough",
|
Label: "CLI Passthrough",
|
||||||
Description: "Show terminal directly instead of chat interface",
|
Description: "Show terminal directly instead of chat interface",
|
||||||
PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
|
PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
|
||||||
ModelFlag: NewParam("--model", "{model}"),
|
ModelFlag: NewParam("--model", "{model}"),
|
||||||
IdleTimeout: 3 * time.Second,
|
IdleTimeout: 3 * time.Second,
|
||||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||||
ResumeFlag: NewParam("-c"),
|
ResumeFlag: NewParam("-c"),
|
||||||
SessionResumeFlag: NewParam("--resume"),
|
SessionResumeFlag: NewParam("--resume"),
|
||||||
MCPStrategy: mcpconfig.ClaudeStrategy{},
|
MCPStrategy: mcpconfig.ClaudeStrategy{},
|
||||||
AutoInjectPrompt: true,
|
AutoInjectPrompt: true,
|
||||||
SubmitSequence: "\r",
|
SubmitSequence: "\r",
|
||||||
DisableBracketedPaste: true,
|
|
||||||
// Claude Code's Ink TUI coalesces multi-byte stdin reads into a
|
// Claude Code's Ink TUI coalesces multi-byte stdin reads into a
|
||||||
// paste burst, absorbing trailing "\r" into the input rather than
|
// paste burst, absorbing a trailing "\r" into the input rather
|
||||||
// dispatching Enter. A short delay before the submit byte forces
|
// than dispatching Enter. A short delay before the submit byte
|
||||||
// it to arrive as a discrete keystroke. 150ms is just over Ink's
|
// makes it arrive as a discrete keystroke; 150ms is just over
|
||||||
// paste-detection window and still feels instant to the user.
|
// 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,
|
SubmitDelay: 150 * time.Millisecond,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,18 +3,33 @@ package agents
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
bracketedPasteStart = "\x1b[200~"
|
bracketedPasteStart = "\x1b[200~"
|
||||||
bracketedPasteEnd = "\x1b[201~"
|
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.
|
// 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
|
// DelayBefore is the pause applied before writing this chunk. The first chunk
|
||||||
// chunk and inherits PassthroughConfig.SubmitDelay on subsequent chunks. Splitting
|
// carries none; continuation body chunks on the unframed path carry
|
||||||
// the prompt body from the submit byte with a delay defeats Ink-style paste-burst
|
// passthroughRawWriteInterval; a separate submit chunk carries
|
||||||
// detection in TUIs like Claude Code (see PassthroughConfig.SubmitDelay).
|
// PassthroughConfig.SubmitDelay so the submit byte arrives as a discrete keystroke
|
||||||
|
// rather than being absorbed into an Ink-style paste burst.
|
||||||
type PassthroughStdinChunk struct {
|
type PassthroughStdinChunk struct {
|
||||||
Data string
|
Data string
|
||||||
DelayBefore time.Duration
|
DelayBefore time.Duration
|
||||||
@@ -26,35 +41,101 @@ func PassthroughSubmitSequence(cfg PassthroughConfig) string {
|
|||||||
return EffectiveSubmitSequence(cfg.SubmitSequence)
|
return EffectiveSubmitSequence(cfg.SubmitSequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write.
|
// stripPasteMarkers removes bracketed-paste delimiters from a prompt before it
|
||||||
// Multi-line prompts use bracketed paste so embedded newlines are not treated as
|
// is delivered. A marker carried inside the body would end the paste early and
|
||||||
// premature Enter presses, unless DisableBracketedPaste is set (Claude Code).
|
// let the remainder reach the TUI as terminal input outside the prompt, which
|
||||||
func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string {
|
// for a CLI agent means unintended commands.
|
||||||
submit := PassthroughSubmitSequence(cfg)
|
//
|
||||||
if cfg.DisableBracketedPaste || !strings.Contains(prompt, "\n") {
|
// Replace until stable: a single pass can be evaded by nesting a marker inside
|
||||||
return prompt + submit
|
// 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.
|
// PlanPassthroughStdinChunks plans PTY stdin writes for a passthrough prompt.
|
||||||
// When SubmitDelay > 0 (Claude path) the prompt body and submit byte are emitted
|
// When SubmitDelay > 0 (Claude path) the submit byte is emitted as its own chunk
|
||||||
// as two chunks with DelayBefore set on the submit chunk so it arrives as a
|
// with DelayBefore set, so it arrives as a discrete keystroke rather than
|
||||||
// discrete keystroke. Otherwise a single atomic write is returned, preserving
|
// trailing bytes of the body. Otherwise the submit sequence rides on the final
|
||||||
// today's behavior for TUIs that handle prompt+submit in one read (Cursor, Codex,
|
// body write, preserving the single-write behavior for TUIs that handle
|
||||||
// OpenCode).
|
// prompt+submit in one read (Cursor, Codex, OpenCode).
|
||||||
func PlanPassthroughStdinChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk {
|
func PlanPassthroughStdinChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk {
|
||||||
|
chunks := planBodyChunks(prompt, cfg)
|
||||||
|
submit := PassthroughSubmitSequence(cfg)
|
||||||
if cfg.SubmitDelay > 0 {
|
if cfg.SubmitDelay > 0 {
|
||||||
body := prompt
|
return append(chunks, PassthroughStdinChunk{Data: submit, DelayBefore: cfg.SubmitDelay})
|
||||||
if !cfg.DisableBracketedPaste && strings.Contains(prompt, "\n") {
|
|
||||||
body = bracketedPasteStart + prompt + bracketedPasteEnd
|
|
||||||
}
|
|
||||||
return []PassthroughStdinChunk{
|
|
||||||
{Data: body},
|
|
||||||
{Data: PassthroughSubmitSequence(cfg), 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.
|
// PlanPassthroughStdinWrites is the legacy string-only view of PlanPassthroughStdinChunks.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package agents
|
package agents
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanPassthroughStdinWrites_SingleAtomicWrite(t *testing.T) {
|
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
|
// Claude's PassthroughConfig sets SubmitDelay so the submit byte arrives as a
|
||||||
// discrete keystroke (defeats Ink's paste-burst detection). Single-line and
|
// discrete keystroke. The body is one chunk and the submit byte another; a
|
||||||
// multi-line prompts both emit body+submit as two chunks; the submit chunk
|
// short single-line prompt keeps its exact bytes, a multi-line prompt is framed
|
||||||
// carries DelayBefore = SubmitDelay.
|
// as a bracketed paste so the TUI absorbs it as one input.
|
||||||
func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) {
|
func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) {
|
||||||
cfg := NewClaudeACP().PassthroughConfig()
|
cfg := NewClaudeACP().PassthroughConfig()
|
||||||
if cfg.SubmitDelay <= 0 {
|
if cfg.SubmitDelay <= 0 {
|
||||||
t.Fatalf("Claude config must set SubmitDelay > 0 for paste-burst workaround, got %v", cfg.SubmitDelay)
|
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)
|
chunks := PlanPassthroughStdinChunks(prompt, cfg)
|
||||||
if len(chunks) != 2 {
|
if len(chunks) != 2 {
|
||||||
t.Fatalf("prompt %q: got %d chunks, want 2 (body, submit): %#v", prompt, len(chunks), chunks)
|
t.Fatalf("prompt %q: got %d chunks, want 2 (body, submit): %#v", prompt, len(chunks), chunks)
|
||||||
}
|
}
|
||||||
if chunks[0].Data != prompt {
|
if chunks[0].Data != wantBody {
|
||||||
t.Errorf("prompt %q: body chunk = %q, want verbatim prompt", prompt, chunks[0].Data)
|
t.Errorf("prompt %q: body chunk = %q, want %q", prompt, chunks[0].Data, wantBody)
|
||||||
}
|
}
|
||||||
if chunks[0].DelayBefore != 0 {
|
if chunks[0].DelayBefore != 0 {
|
||||||
t.Errorf("prompt %q: body chunk DelayBefore = %v, want 0", prompt, chunks[0].DelayBefore)
|
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
|
// Non-Claude TUIs (SubmitDelay == 0) keep the single-atomic-write semantics so we
|
||||||
// don't accidentally regress Cursor/Codex/OpenCode by splitting their submit.
|
// don't accidentally regress Cursor/Codex/OpenCode by splitting their submit.
|
||||||
func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) {
|
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
|
// Sanity: a custom config with SubmitDelay set but no Claude-specific flags
|
||||||
// also splits — the field is the lever, not the Claude struct identity.
|
// also splits — the field is the lever, not the Claude struct identity.
|
||||||
func TestPlanPassthroughStdinChunks_SubmitDelayDrivesSplit(t *testing.T) {
|
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
|
// an MCP client at all. Resolved from the user's stored strategy key; see
|
||||||
// mcpconfig.StrategyByKey for why it is chosen rather than inferred.
|
// mcpconfig.StrategyByKey for why it is chosen rather than inferred.
|
||||||
MCPStrategy mcpconfig.PassthroughMCPStrategy
|
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.
|
// TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools.
|
||||||
@@ -74,24 +77,23 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent {
|
|||||||
a := &TUIAgent{
|
a := &TUIAgent{
|
||||||
StandardPassthrough: StandardPassthrough{
|
StandardPassthrough: StandardPassthrough{
|
||||||
Cfg: PassthroughConfig{
|
Cfg: PassthroughConfig{
|
||||||
Supported: true,
|
Supported: true,
|
||||||
Label: "CLI Passthrough",
|
Label: "CLI Passthrough",
|
||||||
Description: cfg.Desc,
|
Description: cfg.Desc,
|
||||||
PassthroughCmd: NewCommand(cmdArgs...),
|
PassthroughCmd: NewCommand(cmdArgs...),
|
||||||
ModelFlag: cfg.ModelFlag,
|
ModelFlag: cfg.ModelFlag,
|
||||||
IdleTimeout: cfg.IdleTimeout,
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
BufferMaxBytes: cfg.BufferMax,
|
BufferMaxBytes: cfg.BufferMax,
|
||||||
WaitForTerminal: cfg.WaitForTerm,
|
WaitForTerminal: cfg.WaitForTerm,
|
||||||
MCPStrategy: cfg.MCPStrategy,
|
MCPStrategy: cfg.MCPStrategy,
|
||||||
|
DisableBracketedPaste: cfg.DisableBracketedPaste,
|
||||||
// Ink-based TUIs (Claude Code and similar) coalesce multi-byte
|
// Ink-based TUIs (Claude Code and similar) coalesce multi-byte
|
||||||
// stdin reads into a paste burst, absorbing a trailing "\r" into
|
// stdin reads into a paste burst, absorbing a trailing "\r" into
|
||||||
// the pasted content instead of dispatching Enter, and enable
|
// the pasted content instead of dispatching Enter. The submit
|
||||||
// bracketed-paste mode so ESC[200~…ESC[201~ delimiters break
|
// byte is therefore a separate delayed keystroke, and the body
|
||||||
// input. Send prompt bytes verbatim and split the submit byte
|
// travels as a bracketed paste so it arrives whole at any
|
||||||
// into a discrete keystroke so programmatic PTY prompts submit.
|
// length. Matches the built-in Claude passthrough agent.
|
||||||
// Matches the built-in Claude passthrough agent.
|
SubmitDelay: 150 * time.Millisecond,
|
||||||
DisableBracketedPaste: true,
|
|
||||||
SubmitDelay: 150 * time.Millisecond,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude
|
// 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
|
// Code. Ink absorbs a trailing "\r" into a pasted burst instead of dispatching
|
||||||
// trailing "\r" into the pasted content instead of dispatching Enter, and it
|
// Enter, so the submit byte needs its own delayed write. Bracketed-paste
|
||||||
// enables bracketed-paste mode so ESC[200~…ESC[201~ delimiters break input.
|
// framing stays on: without it a body larger than one terminal read reaches the
|
||||||
// The built-in Claude passthrough agent works around both; custom TUI agents
|
// TUI as a burst it silently drops whole reads from. Custom TUI agents must
|
||||||
// must inherit the same defaults or programmatic PTY prompts (peer messaging,
|
// inherit both defaults or programmatic PTY prompts (peer messaging,
|
||||||
// queued-message drain, workflow auto-start) land in the input box unsubmitted.
|
// queued-message drain, workflow auto-start) arrive truncated or unsubmitted.
|
||||||
func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
|
func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
|
||||||
a := NewTUIAgent(TUIAgentConfig{
|
a := NewTUIAgent(TUIAgentConfig{
|
||||||
AgentID: "custom-tui",
|
AgentID: "custom-tui",
|
||||||
@@ -24,8 +24,8 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
pt := a.PassthroughConfig()
|
pt := a.PassthroughConfig()
|
||||||
if !pt.DisableBracketedPaste {
|
if pt.DisableBracketedPaste {
|
||||||
t.Error("DisableBracketedPaste = false, want true (send prompt bytes verbatim; Ink breaks on bracketed-paste delimiters)")
|
t.Error("DisableBracketedPaste = true, want false (framing is what carries a body past one terminal read)")
|
||||||
}
|
}
|
||||||
if pt.SubmitDelay != 150*time.Millisecond {
|
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)
|
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
|
// Protocol selects the runtime kandev drives the command with. Empty means
|
||||||
// terminal passthrough.
|
// terminal passthrough.
|
||||||
Protocol CustomAgentProtocol
|
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
|
// 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 nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
|
||||||
}
|
}
|
||||||
return agents.NewTUIAgent(agents.TUIAgentConfig{
|
return agents.NewTUIAgent(agents.TUIAgentConfig{
|
||||||
AgentID: spec.Slug,
|
AgentID: spec.Slug,
|
||||||
AgentName: spec.Slug,
|
AgentName: spec.Slug,
|
||||||
Command: binary,
|
Command: binary,
|
||||||
Desc: spec.Description,
|
Desc: spec.Description,
|
||||||
Display: spec.DisplayName,
|
Display: spec.DisplayName,
|
||||||
WaitForTerm: true,
|
WaitForTerm: true,
|
||||||
CommandArgs: args,
|
CommandArgs: args,
|
||||||
MCPStrategy: strategy,
|
MCPStrategy: strategy,
|
||||||
|
DisableBracketedPaste: spec.DisableBracketedPaste,
|
||||||
}), nil
|
}), nil
|
||||||
case CustomAgentProtocolACP:
|
case CustomAgentProtocolACP:
|
||||||
if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone {
|
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) {
|
func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) {
|
||||||
log := newTestLogger()
|
log := newTestLogger()
|
||||||
reg := NewRegistry(log)
|
reg := NewRegistry(log)
|
||||||
|
|||||||
@@ -1721,8 +1721,12 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
|
|||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
}
|
}
|
||||||
for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) {
|
for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) {
|
||||||
if chunk.DelayBefore > 0 {
|
if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil {
|
||||||
time.Sleep(chunk.DelayBefore)
|
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 {
|
if err := runner.WriteStdin(processID, chunk.Data); err != nil {
|
||||||
m.logger.Warn("autoInjectInitialPrompt write failed",
|
m.logger.Warn("autoInjectInitialPrompt write failed",
|
||||||
@@ -1738,6 +1742,20 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
|
|||||||
zap.Int("description_len", len(description)))
|
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.
|
// ResolvePassthroughConfig returns the PassthroughConfig for a session's agent.
|
||||||
// Used by callers outside this package (e.g. orchestrator) that need the submit
|
// Used by callers outside this package (e.g. orchestrator) that need the submit
|
||||||
// sequence to write to PTY stdin.
|
// sequence to write to PTY stdin.
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type CreateCustomTUIAgentRequest struct {
|
|||||||
// Protocol is the runtime kandev drives the command with
|
// Protocol is the runtime kandev drives the command with
|
||||||
// (registry.CustomAgentProtocol*). Empty means terminal passthrough.
|
// (registry.CustomAgentProtocol*). Empty means terminal passthrough.
|
||||||
Protocol string
|
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
|
// 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
|
// Register in the in-memory registry
|
||||||
if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
|
if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Command: req.Command,
|
Command: req.Command,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Model: req.Model,
|
Model: req.Model,
|
||||||
CommandArgs: req.CommandArgs,
|
CommandArgs: req.CommandArgs,
|
||||||
MCPStrategyKey: req.MCPStrategy,
|
MCPStrategyKey: req.MCPStrategy,
|
||||||
Protocol: protocol,
|
Protocol: protocol,
|
||||||
|
DisableBracketedPaste: req.DisableBracketedPaste,
|
||||||
}); regErr != nil {
|
}); regErr != nil {
|
||||||
return nil, fmt.Errorf("failed to register agent: %w", regErr)
|
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
|
// Persist to DB
|
||||||
acp := protocol == registry.CustomAgentProtocolACP
|
acp := protocol == registry.CustomAgentProtocolACP
|
||||||
tuiConfig := &models.TUIConfigJSON{
|
tuiConfig := &models.TUIConfigJSON{
|
||||||
Command: req.Command,
|
Command: req.Command,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Model: req.Model,
|
Model: req.Model,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
CommandArgs: req.CommandArgs,
|
CommandArgs: req.CommandArgs,
|
||||||
WaitForTerminal: true,
|
WaitForTerminal: true,
|
||||||
MCPStrategy: req.MCPStrategy,
|
MCPStrategy: req.MCPStrategy,
|
||||||
Protocol: req.Protocol,
|
Protocol: req.Protocol,
|
||||||
|
DisableBracketedPaste: req.DisableBracketedPaste,
|
||||||
}
|
}
|
||||||
agent := &models.Agent{
|
agent := &models.Agent{
|
||||||
Name: slug,
|
Name: slug,
|
||||||
@@ -251,14 +256,15 @@ func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
|
|||||||
// silently miss the other.
|
// silently miss the other.
|
||||||
func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec {
|
func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec {
|
||||||
return registry.CustomTUIAgentSpec{
|
return registry.CustomTUIAgentSpec{
|
||||||
Slug: name,
|
Slug: name,
|
||||||
DisplayName: cfg.DisplayName,
|
DisplayName: cfg.DisplayName,
|
||||||
Command: cfg.Command,
|
Command: cfg.Command,
|
||||||
Description: cfg.Description,
|
Description: cfg.Description,
|
||||||
Model: cfg.Model,
|
Model: cfg.Model,
|
||||||
CommandArgs: cfg.CommandArgs,
|
CommandArgs: cfg.CommandArgs,
|
||||||
MCPStrategyKey: cfg.MCPStrategy,
|
MCPStrategyKey: cfg.MCPStrategy,
|
||||||
Protocol: registry.CustomAgentProtocol(cfg.Protocol),
|
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
|
// TestCreateCustomTUIAgent_NoCommandArgs keeps the existing behaviour intact
|
||||||
// when the caller omits the field.
|
// when the caller omits the field.
|
||||||
func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
|
func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
|
||||||
|
|||||||
@@ -1218,14 +1218,15 @@ func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentPro
|
|||||||
}
|
}
|
||||||
if agent.TUIConfig != nil {
|
if agent.TUIConfig != nil {
|
||||||
result.TUIConfig = &dto.TUIConfigDTO{
|
result.TUIConfig = &dto.TUIConfigDTO{
|
||||||
Command: agent.TUIConfig.Command,
|
Command: agent.TUIConfig.Command,
|
||||||
DisplayName: agent.TUIConfig.DisplayName,
|
DisplayName: agent.TUIConfig.DisplayName,
|
||||||
Model: agent.TUIConfig.Model,
|
Model: agent.TUIConfig.Model,
|
||||||
Description: agent.TUIConfig.Description,
|
Description: agent.TUIConfig.Description,
|
||||||
CommandArgs: agent.TUIConfig.CommandArgs,
|
CommandArgs: agent.TUIConfig.CommandArgs,
|
||||||
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
||||||
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
||||||
Protocol: agent.TUIConfig.Protocol,
|
Protocol: agent.TUIConfig.Protocol,
|
||||||
|
DisableBracketedPaste: agent.TUIConfig.DisableBracketedPaste,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.agentRegistry != nil {
|
if c.agentRegistry != nil {
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ type TUIConfigDTO struct {
|
|||||||
// Protocol is the runtime kandev drives the command with ("" = terminal
|
// Protocol is the runtime kandev drives the command with ("" = terminal
|
||||||
// passthrough, "acp" = ACP on stdin/stdout).
|
// passthrough, "acp" = ACP on stdin/stdout).
|
||||||
Protocol string `json:"protocol,omitempty"`
|
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 {
|
type AgentDTO struct {
|
||||||
|
|||||||
@@ -26,17 +26,21 @@ type createCustomTUIAgentRequest struct {
|
|||||||
// Protocol is the runtime kandev drives the command with: empty (the
|
// Protocol is the runtime kandev drives the command with: empty (the
|
||||||
// default) for terminal passthrough, "acp" for ACP on stdin/stdout.
|
// default) for terminal passthrough, "acp" for ACP on stdin/stdout.
|
||||||
Protocol string `json:"protocol"`
|
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 {
|
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
|
||||||
return controller.CreateCustomTUIAgentRequest{
|
return controller.CreateCustomTUIAgentRequest{
|
||||||
DisplayName: r.DisplayName,
|
DisplayName: r.DisplayName,
|
||||||
Model: r.Model,
|
Model: r.Model,
|
||||||
Command: r.Command,
|
Command: r.Command,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
CommandArgs: r.CommandArgs,
|
CommandArgs: r.CommandArgs,
|
||||||
MCPStrategy: r.MCPStrategy,
|
MCPStrategy: r.MCPStrategy,
|
||||||
Protocol: r.Protocol,
|
Protocol: r.Protocol,
|
||||||
|
DisableBracketedPaste: r.DisableBracketedPaste,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) {
|
|||||||
payload := `{
|
payload := `{
|
||||||
"display_name": "Spaced Args",
|
"display_name": "Spaced Args",
|
||||||
"command": "my-cli",
|
"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
|
var body createCustomTUIAgentRequest
|
||||||
@@ -30,6 +31,9 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) {
|
|||||||
if req.DisplayName != "Spaced Args" || req.Command != "my-cli" {
|
if req.DisplayName != "Spaced Args" || req.Command != "my-cli" {
|
||||||
t.Errorf("unexpected mapping: %+v", req)
|
t.Errorf("unexpected mapping: %+v", req)
|
||||||
}
|
}
|
||||||
|
if !req.DisableBracketedPaste {
|
||||||
|
t.Error("DisableBracketedPaste = false, want true")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateCustomTUIAgentRequest_OmittedCommandArgs keeps the field optional.
|
// TestCreateCustomTUIAgentRequest_OmittedCommandArgs keeps the field optional.
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type TUIConfigJSON struct {
|
|||||||
// (registry.CustomAgentProtocol*). Empty — the value every row written
|
// (registry.CustomAgentProtocol*). Empty — the value every row written
|
||||||
// before this field existed decodes to — means terminal passthrough.
|
// before this field existed decodes to — means terminal passthrough.
|
||||||
Protocol string `json:"protocol,omitempty"`
|
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 {
|
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) {
|
t.Run("cancellation interrupts submit delay", func(t *testing.T) {
|
||||||
repo := setupTestRepo(t)
|
repo := setupTestRepo(t)
|
||||||
seedSession(t, repo, "t1", "s1", "step1")
|
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.
|
// 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
|
// 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
|
// 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
|
// atomic write. Config resolution is required: an unknown agent contract must not fall
|
||||||
// transient lookup error never silently swallows the prompt.
|
// back to one unframed write, because a long prompt can be silently truncated.
|
||||||
//
|
//
|
||||||
// Callers that already hold the per-session cancellation guard must use
|
// Callers that already hold the per-session cancellation guard must use
|
||||||
// PreparePassthroughRunning + writePassthroughPrompt instead (see handleAgentReady); this
|
// 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 {
|
func (s *Service) writePassthroughPrompt(ctx context.Context, sessionID, content string) error {
|
||||||
pt, cfgErr := s.agentManager.ResolvePassthroughConfig(ctx, sessionID)
|
pt, cfgErr := s.agentManager.ResolvePassthroughConfig(ctx, sessionID)
|
||||||
if cfgErr != nil {
|
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.String("session_id", sessionID),
|
||||||
zap.Error(cfgErr))
|
zap.Error(cfgErr))
|
||||||
}
|
return fmt.Errorf("resolve passthrough config: %w", 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
|
|
||||||
}
|
}
|
||||||
for _, chunk := range agents.PlanPassthroughStdinChunks(content, pt) {
|
for _, chunk := range agents.PlanPassthroughStdinChunks(content, pt) {
|
||||||
if chunk.DelayBefore > 0 {
|
if chunk.DelayBefore > 0 {
|
||||||
|
|||||||
@@ -724,8 +724,8 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session
|
|||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
}
|
}
|
||||||
for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) {
|
for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) {
|
||||||
if chunk.DelayBefore > 0 {
|
if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil {
|
||||||
time.Sleep(chunk.DelayBefore)
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := e.agentManager.WritePassthroughStdin(ctx, sessionID, chunk.Data); err != nil {
|
if err := e.agentManager.WritePassthroughStdin(ctx, sessionID, chunk.Data); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write to passthrough stdin: %w", err)
|
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
|
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) {
|
func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) {
|
||||||
if len(attachments) == 0 {
|
if len(attachments) == 0 {
|
||||||
return prompt, nil
|
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) {
|
func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) {
|
||||||
repo := newMockRepository()
|
repo := newMockRepository()
|
||||||
agentManager := &mockAgentManager{
|
agentManager := &mockAgentManager{
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export async function createCustomTUIAgent(
|
|||||||
description?: string;
|
description?: string;
|
||||||
mcp_strategy?: string;
|
mcp_strategy?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
|
disable_bracketed_paste?: boolean;
|
||||||
},
|
},
|
||||||
options?: ApiRequestOptions,
|
options?: ApiRequestOptions,
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export type TUIConfig = {
|
|||||||
* calls, models, and modes.
|
* calls, models, and modes.
|
||||||
*/
|
*/
|
||||||
protocol?: CustomAgentProtocol;
|
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. */
|
/** 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.
|
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
|
### 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