From 5cb908e4adf53fc6bca9727dc4f3a7d534ee38fd Mon Sep 17 00:00:00 2001 From: Dmitry Koryakov <43443444+KoryakovDmitry@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:45:00 +0200 Subject: [PATCH] fix: deliver long CLI passthrough prompts intact (#3894) Co-authored-by: Carlos Florencio --- apps/backend/internal/agent/agents/agent.go | 18 +- .../internal/agent/agents/claude_acp.go | 35 ++-- .../agent/agents/passthrough_payload.go | 133 +++++++++++--- .../agent/agents/passthrough_payload_test.go | 164 +++++++++++++++++- .../internal/agent/agents/tui_agent.go | 34 ++-- .../internal/agent/agents/tui_agent_test.go | 16 +- .../internal/agent/registry/custom_tui.go | 20 ++- .../agent/registry/custom_tui_test.go | 27 +++ .../runtime/lifecycle/manager_passthrough.go | 22 ++- .../agent/settings/controller/custom_tui.go | 54 +++--- .../settings/controller/custom_tui_test.go | 28 +++ .../agent/settings/controller/profile_crud.go | 17 +- .../internal/agent/settings/dto/dto.go | 3 + .../settings/handlers/custom_tui_handlers.go | 18 +- .../handlers/custom_tui_handlers_test.go | 6 +- .../internal/agent/settings/models/models.go | 3 + .../orchestrator/event_handlers_test.go | 19 ++ .../orchestrator/event_handlers_workflow.go | 13 +- .../executor/executor_interaction.go | 18 +- .../executor_passthrough_prompt_test.go | 31 ++++ apps/web/lib/api/domains/settings-api.ts | 1 + apps/web/lib/types/http-agents.ts | 2 + .../plan.md | 134 ++++++++++++++ .../task-01-frame-passthrough-prompt-body.md | 123 +++++++++++++ ...ask-02-pace-unframed-passthrough-writes.md | 115 ++++++++++++ .../specs/cli/requirements/cli-mode-parity.md | 2 +- .../passthrough-prompt-delivery.md | 96 ++++++++++ .../passthrough-prompt-delivery.md | 120 +++++++++++++ 28 files changed, 1129 insertions(+), 143 deletions(-) create mode 100644 docs/plans/passthrough-prompt-delivery-integrity/plan.md create mode 100644 docs/plans/passthrough-prompt-delivery-integrity/task-01-frame-passthrough-prompt-body.md create mode 100644 docs/plans/passthrough-prompt-delivery-integrity/task-02-pace-unframed-passthrough-writes.md create mode 100644 docs/specs/cli/requirements/passthrough-prompt-delivery.md create mode 100644 docs/specs/cli/system-design/passthrough-prompt-delivery.md diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 9bbe9ff9da..82ea8043a5 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -399,17 +399,19 @@ type PassthroughConfig struct { // and when routing chat-compose messages to the PTY. "\r" for most TUIs. // Empty inherits DefaultPassthroughSubmitSequence at PTY write sites. SubmitSequence string - // DisableBracketedPaste sends prompt bytes verbatim (plus SubmitSequence). - // Claude Code enables bracketed-paste *mode* (?2004h) in its Ink TUI; injecting - // ESC[200~…ESC[201~ delimiters breaks input (nothing appears in the prompt). + // DisableBracketedPaste sends the prompt body without ESC[200~…ESC[201~ + // delimiters. The planner then paces the body in writes that each fit within + // one terminal read, because a TUI can drop whole reads of a larger unframed + // burst. Set it only for a TUI that does not accept bracketed-paste input; + // framed bodies arrive whole at any length. DisableBracketedPaste bool - // SubmitDelay is the wait inserted before each non-first chunk when writing the - // prompt+submit sequence to PTY stdin. Ink-based TUIs (Claude Code) detect a + // SubmitDelay is the wait inserted before the separate submit chunk when + // writing a prompt to PTY stdin. Ink-based TUIs (Claude Code) detect a // "paste burst" when many stdin bytes arrive in one read and absorb the // trailing \r into the pasted content instead of dispatching it as Enter. - // Splitting the prompt body from the submit byte with a small delay forces the - // submit to arrive as a discrete keystroke. 0 disables (other TUIs handle one - // atomic write fine). + // Writing the submit byte on its own after a small delay makes it arrive as + // a discrete keystroke. 0 appends the submit sequence to the final body + // write (other TUIs handle prompt and submit in one read). SubmitDelay time.Duration } diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 354bde388a..80204dca01 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -52,24 +52,25 @@ func NewClaudeACP() *ClaudeACP { StandardPassthrough: StandardPassthrough{ PermSettings: claudeACPPermSettings, Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"), - ModelFlag: NewParam("--model", "{model}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - ResumeFlag: NewParam("-c"), - SessionResumeFlag: NewParam("--resume"), - MCPStrategy: mcpconfig.ClaudeStrategy{}, - AutoInjectPrompt: true, - SubmitSequence: "\r", - DisableBracketedPaste: true, + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"), + ModelFlag: NewParam("--model", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("-c"), + SessionResumeFlag: NewParam("--resume"), + MCPStrategy: mcpconfig.ClaudeStrategy{}, + AutoInjectPrompt: true, + SubmitSequence: "\r", // Claude Code's Ink TUI coalesces multi-byte stdin reads into a - // paste burst, absorbing trailing "\r" into the input rather than - // dispatching Enter. A short delay before the submit byte forces - // it to arrive as a discrete keystroke. 150ms is just over Ink's - // paste-detection window and still feels instant to the user. + // paste burst, absorbing a trailing "\r" into the input rather + // than dispatching Enter. A short delay before the submit byte + // makes it arrive as a discrete keystroke; 150ms is just over + // Ink's paste-detection window and still feels instant. Because + // the submit byte is its own write, the body travels as a + // bracketed paste, which the TUI absorbs whole at any length. SubmitDelay: 150 * time.Millisecond, }, }, diff --git a/apps/backend/internal/agent/agents/passthrough_payload.go b/apps/backend/internal/agent/agents/passthrough_payload.go index 5dd836f8be..6e09660368 100644 --- a/apps/backend/internal/agent/agents/passthrough_payload.go +++ b/apps/backend/internal/agent/agents/passthrough_payload.go @@ -3,18 +3,33 @@ package agents import ( "strings" "time" + "unicode/utf8" ) const ( bracketedPasteStart = "\x1b[200~" bracketedPasteEnd = "\x1b[201~" + + // passthroughRawSafeWriteBytes caps one unframed PTY write below the host's + // single-read capacity (1022 bytes on macOS). A TUI can drop whole reads of + // an unframed burst without reporting it, so an unframed write never exceeds + // one read. Larger bodies are framed as a bracketed paste, which the TUI + // absorbs in one piece at any length. + passthroughRawSafeWriteBytes = 400 + + // passthroughRawWriteInterval spaces consecutive unframed writes to give the + // TUI time to drain one read before the next arrives. PTY writes provide no + // acknowledgement from the receiving TUI, so this is an empirical pacing + // policy rather than a delivery receipt. + passthroughRawWriteInterval = 30 * time.Millisecond ) // PassthroughStdinChunk is one PTY stdin write planned for a passthrough prompt. -// DelayBefore is sleep applied before writing this chunk; it is zero on the first -// chunk and inherits PassthroughConfig.SubmitDelay on subsequent chunks. Splitting -// the prompt body from the submit byte with a delay defeats Ink-style paste-burst -// detection in TUIs like Claude Code (see PassthroughConfig.SubmitDelay). +// DelayBefore is the pause applied before writing this chunk. The first chunk +// carries none; continuation body chunks on the unframed path carry +// passthroughRawWriteInterval; a separate submit chunk carries +// PassthroughConfig.SubmitDelay so the submit byte arrives as a discrete keystroke +// rather than being absorbed into an Ink-style paste burst. type PassthroughStdinChunk struct { Data string DelayBefore time.Duration @@ -26,35 +41,101 @@ func PassthroughSubmitSequence(cfg PassthroughConfig) string { return EffectiveSubmitSequence(cfg.SubmitSequence) } -// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write. -// Multi-line prompts use bracketed paste so embedded newlines are not treated as -// premature Enter presses, unless DisableBracketedPaste is set (Claude Code). -func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string { - submit := PassthroughSubmitSequence(cfg) - if cfg.DisableBracketedPaste || !strings.Contains(prompt, "\n") { - return prompt + submit +// stripPasteMarkers removes bracketed-paste delimiters from a prompt before it +// is delivered. A marker carried inside the body would end the paste early and +// let the remainder reach the TUI as terminal input outside the prompt, which +// for a CLI agent means unintended commands. +// +// Replace until stable: a single pass can be evaded by nesting a marker inside +// itself (e.g. "\x1b[2\x1b[201~01~" collapses to a live marker after one pass). +func stripPasteMarkers(value string) string { + for strings.Contains(value, bracketedPasteStart) || strings.Contains(value, bracketedPasteEnd) { + value = strings.ReplaceAll(value, bracketedPasteStart, "") + value = strings.ReplaceAll(value, bracketedPasteEnd, "") } - return bracketedPasteStart + prompt + bracketedPasteEnd + submit + return value +} + +// framePassthroughBody reports whether the body travels as a bracketed paste. +// Framing is what lets a body outgrow the host's single-read capacity: the TUI +// absorbs a delimited paste in one piece, while an equally large unframed burst +// loses whole reads. A short single-line body is written unframed. +func framePassthroughBody(prompt string, cfg PassthroughConfig) bool { + if cfg.DisableBracketedPaste { + return false + } + return strings.Contains(prompt, "\n") || len(prompt) > passthroughRawSafeWriteBytes +} + +// planBodyChunks plans the PTY writes that carry the prompt body, excluding the +// submit sequence. A framed body is one write at any length; an unframed body is +// paced so no single write exceeds what the TUI can absorb in one read. +func planBodyChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk { + if framePassthroughBody(prompt, cfg) { + body := stripPasteMarkers(prompt) + return []PassthroughStdinChunk{{Data: bracketedPasteStart + body + bracketedPasteEnd}} + } + return paceRawBody(prompt) +} + +// paceRawBody splits an unframed body into writes within the raw-safe size, +// cutting on rune boundaries so no write ends inside a multi-byte character. +// Every continuation carries the inter-write delay; the first carries none. +func paceRawBody(body string) []PassthroughStdinChunk { + if len(body) <= passthroughRawSafeWriteBytes { + return []PassthroughStdinChunk{{Data: body}} + } + var chunks []PassthroughStdinChunk + for len(body) > 0 { + cut := passthroughRawSafeWriteBytes + if cut >= len(body) { + cut = len(body) + } else { + for cut > 0 && !utf8.RuneStart(body[cut]) { + cut-- + } + if cut == 0 { + // Not valid UTF-8; fall back to the byte budget rather than + // looping forever on a prompt we cannot split cleanly. + cut = passthroughRawSafeWriteBytes + } + } + var delay time.Duration + if len(chunks) > 0 { + delay = passthroughRawWriteInterval + } + chunks = append(chunks, PassthroughStdinChunk{Data: body[:cut], DelayBefore: delay}) + body = body[cut:] + } + return chunks +} + +// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write. +// The body is framed as a bracketed paste unless the agent cannot accept the +// delimiters, so embedded newlines are not treated as premature Enter presses +// and a long body is not split across terminal reads the TUI can drop. +func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string { + if framePassthroughBody(prompt, cfg) { + body := stripPasteMarkers(prompt) + return bracketedPasteStart + body + bracketedPasteEnd + PassthroughSubmitSequence(cfg) + } + return prompt + PassthroughSubmitSequence(cfg) } // PlanPassthroughStdinChunks plans PTY stdin writes for a passthrough prompt. -// When SubmitDelay > 0 (Claude path) the prompt body and submit byte are emitted -// as two chunks with DelayBefore set on the submit chunk so it arrives as a -// discrete keystroke. Otherwise a single atomic write is returned, preserving -// today's behavior for TUIs that handle prompt+submit in one read (Cursor, Codex, -// OpenCode). +// When SubmitDelay > 0 (Claude path) the submit byte is emitted as its own chunk +// with DelayBefore set, so it arrives as a discrete keystroke rather than +// trailing bytes of the body. Otherwise the submit sequence rides on the final +// body write, preserving the single-write behavior for TUIs that handle +// prompt+submit in one read (Cursor, Codex, OpenCode). func PlanPassthroughStdinChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk { + chunks := planBodyChunks(prompt, cfg) + submit := PassthroughSubmitSequence(cfg) if cfg.SubmitDelay > 0 { - body := prompt - if !cfg.DisableBracketedPaste && strings.Contains(prompt, "\n") { - body = bracketedPasteStart + prompt + bracketedPasteEnd - } - return []PassthroughStdinChunk{ - {Data: body}, - {Data: PassthroughSubmitSequence(cfg), DelayBefore: cfg.SubmitDelay}, - } + return append(chunks, PassthroughStdinChunk{Data: submit, DelayBefore: cfg.SubmitDelay}) } - return []PassthroughStdinChunk{{Data: BuildPassthroughPayload(prompt, cfg)}} + chunks[len(chunks)-1].Data += submit + return chunks } // PlanPassthroughStdinWrites is the legacy string-only view of PlanPassthroughStdinChunks. diff --git a/apps/backend/internal/agent/agents/passthrough_payload_test.go b/apps/backend/internal/agent/agents/passthrough_payload_test.go index e1d98383fa..2907ca11eb 100644 --- a/apps/backend/internal/agent/agents/passthrough_payload_test.go +++ b/apps/backend/internal/agent/agents/passthrough_payload_test.go @@ -1,8 +1,10 @@ package agents import ( + "strings" "testing" "time" + "unicode/utf8" ) func TestPlanPassthroughStdinWrites_SingleAtomicWrite(t *testing.T) { @@ -26,22 +28,26 @@ func TestPlanPassthroughStdinWrites_SingleLine(t *testing.T) { } // Claude's PassthroughConfig sets SubmitDelay so the submit byte arrives as a -// discrete keystroke (defeats Ink's paste-burst detection). Single-line and -// multi-line prompts both emit body+submit as two chunks; the submit chunk -// carries DelayBefore = SubmitDelay. +// discrete keystroke. The body is one chunk and the submit byte another; a +// short single-line prompt keeps its exact bytes, a multi-line prompt is framed +// as a bracketed paste so the TUI absorbs it as one input. func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) { cfg := NewClaudeACP().PassthroughConfig() if cfg.SubmitDelay <= 0 { t.Fatalf("Claude config must set SubmitDelay > 0 for paste-burst workaround, got %v", cfg.SubmitDelay) } - for _, prompt := range []string{"hello", "### Review Comments\n\n> fix"} { + cases := map[string]string{ + "hello": "hello", + "### Review Comments\n\n> fix": bracketedPasteStart + "### Review Comments\n\n> fix" + bracketedPasteEnd, + } + for prompt, wantBody := range cases { chunks := PlanPassthroughStdinChunks(prompt, cfg) if len(chunks) != 2 { t.Fatalf("prompt %q: got %d chunks, want 2 (body, submit): %#v", prompt, len(chunks), chunks) } - if chunks[0].Data != prompt { - t.Errorf("prompt %q: body chunk = %q, want verbatim prompt", prompt, chunks[0].Data) + if chunks[0].Data != wantBody { + t.Errorf("prompt %q: body chunk = %q, want %q", prompt, chunks[0].Data, wantBody) } if chunks[0].DelayBefore != 0 { t.Errorf("prompt %q: body chunk DelayBefore = %v, want 0", prompt, chunks[0].DelayBefore) @@ -55,6 +61,86 @@ func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) { } } +// A body larger than one terminal read must travel as one framed write, with the +// prompt recoverable byte-for-byte from between the markers. +func TestPlanPassthroughStdinChunks_FramesBodyLargerThanOneRead(t *testing.T) { + cfg := NewClaudeACP().PassthroughConfig() + prompt := strings.Repeat("строка контекста для агента\n", 400) + if len(prompt) < 8000 { + t.Fatalf("fixture too small to exercise multi-read delivery: %d bytes", len(prompt)) + } + + chunks := PlanPassthroughStdinChunks(prompt, cfg) + if len(chunks) != 2 { + t.Fatalf("got %d chunks, want 2 (framed body, submit): lengths %v", len(chunks), chunkLengths(chunks)) + } + body := chunks[0].Data + if !strings.HasPrefix(body, bracketedPasteStart) || !strings.HasSuffix(body, bracketedPasteEnd) { + t.Fatalf("body is not bracketed: prefix=%q suffix=%q", body[:6], body[len(body)-6:]) + } + inner := strings.TrimSuffix(strings.TrimPrefix(body, bracketedPasteStart), bracketedPasteEnd) + if inner != prompt { + t.Errorf("framed body lost content: got %d bytes, want %d", len(inner), len(prompt)) + } +} + +// A single-line prompt past the raw-safe write size is just as lossy unframed, +// so length alone must trigger framing. +func TestPlanPassthroughStdinChunks_FramesLongSingleLine(t *testing.T) { + cfg := NewClaudeACP().PassthroughConfig() + prompt := strings.Repeat("x", passthroughRawSafeWriteBytes+1) + + body := PlanPassthroughStdinChunks(prompt, cfg)[0].Data + if !strings.HasPrefix(body, bracketedPasteStart) { + t.Fatalf("long single-line body was not framed: %q...", body[:20]) + } +} + +// A marker inside the prompt would end the paste early and let the remainder +// reach the TUI as terminal input outside the prompt. +func TestPlanPassthroughStdinChunks_NeutralizesFramingTerminators(t *testing.T) { + cfg := NewClaudeACP().PassthroughConfig() + prompt := "before\n" + bracketedPasteEnd + "rm -rf /\n" + bracketedPasteStart + "after" + + body := PlanPassthroughStdinChunks(prompt, cfg)[0].Data + if strings.Count(body, bracketedPasteStart) != 1 || strings.Count(body, bracketedPasteEnd) != 1 { + t.Fatalf("markers from the prompt survived framing: %q", body) + } + inner := strings.TrimSuffix(strings.TrimPrefix(body, bracketedPasteStart), bracketedPasteEnd) + if strings.Contains(inner, "\x1b[") { + t.Errorf("escape sequence left inside the framed body: %q", inner) + } + for _, want := range []string{"before", "rm -rf /", "after"} { + if !strings.Contains(inner, want) { + t.Errorf("neutralization dropped prompt text %q from %q", want, inner) + } + } +} + +func TestPlanPassthroughStdinChunks_UnframedPreservesFramingMarkers(t *testing.T) { + cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true} + prompt := "literal " + bracketedPasteStart + " marker " + bracketedPasteEnd + + chunks := PlanPassthroughStdinChunks(prompt, cfg) + if len(chunks) != 1 { + t.Fatalf("got %d chunks, want one atomic write: %#v", len(chunks), chunks) + } + if got, want := chunks[0].Data, prompt+"\r"; got != want { + t.Errorf("unframed payload = %q, want %q", got, want) + } + if got := BuildPassthroughPayload(prompt, cfg); got != prompt+"\r" { + t.Errorf("BuildPassthroughPayload = %q, want literal markers preserved", got) + } +} + +func chunkLengths(chunks []PassthroughStdinChunk) []int { + out := make([]int, len(chunks)) + for i, c := range chunks { + out[i] = len(c.Data) + } + return out +} + // Non-Claude TUIs (SubmitDelay == 0) keep the single-atomic-write semantics so we // don't accidentally regress Cursor/Codex/OpenCode by splitting their submit. func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) { @@ -71,6 +157,72 @@ func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) { } } +// An agent that cannot accept paste framing must still never receive a burst +// larger than one terminal read, so its body is written in paced pieces. +func TestPlanPassthroughStdinChunks_PacesUnframedBody(t *testing.T) { + cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true, SubmitDelay: 150 * time.Millisecond} + prompt := strings.Repeat("unframed body line\n", 200) + + chunks := PlanPassthroughStdinChunks(prompt, cfg) + if len(chunks) < 3 { + t.Fatalf("got %d chunks for a %d-byte body, want it paced: %v", len(chunks), len(prompt), chunkLengths(chunks)) + } + + var body strings.Builder + for i, chunk := range chunks[:len(chunks)-1] { + if len(chunk.Data) > passthroughRawSafeWriteBytes { + t.Errorf("body chunk %d is %d bytes, want at most %d", i, len(chunk.Data), passthroughRawSafeWriteBytes) + } + wantDelay := passthroughRawWriteInterval + if i == 0 { + wantDelay = 0 + } + if chunk.DelayBefore != wantDelay { + t.Errorf("body chunk %d DelayBefore = %v, want %v", i, chunk.DelayBefore, wantDelay) + } + body.WriteString(chunk.Data) + } + if body.String() != prompt { + t.Errorf("paced body lost content: got %d bytes, want %d", body.Len(), len(prompt)) + } + + submit := chunks[len(chunks)-1] + if submit.Data != "\r" || submit.DelayBefore != cfg.SubmitDelay { + t.Errorf("submit chunk = %q after %v, want %q after %v", submit.Data, submit.DelayBefore, "\r", cfg.SubmitDelay) + } +} + +// Splitting by bytes would corrupt non-ASCII prompts. +func TestPlanPassthroughStdinChunks_UnframedSplitsOnRuneBoundaries(t *testing.T) { + cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true, SubmitDelay: 150 * time.Millisecond} + prompt := strings.Repeat("сообщение агенту ", 200) + + for i, chunk := range PlanPassthroughStdinChunks(prompt, cfg) { + if !utf8.ValidString(chunk.Data) { + t.Fatalf("chunk %d ends inside a multi-byte character: %q", i, chunk.Data) + } + } +} + +// A TUI with no submit delay still gets a paced body; its submit sequence rides +// on the final write so prompt and submit stay one read for short prompts. +func TestPlanPassthroughStdinChunks_PacesUnframedBodyWithoutSubmitDelay(t *testing.T) { + cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true} + prompt := strings.Repeat("x", passthroughRawSafeWriteBytes*3) + + chunks := PlanPassthroughStdinChunks(prompt, cfg) + if len(chunks) != 3 { + t.Fatalf("got %d chunks, want 3 paced writes: %v", len(chunks), chunkLengths(chunks)) + } + var joined strings.Builder + for _, chunk := range chunks { + joined.WriteString(chunk.Data) + } + if joined.String() != prompt+"\r" { + t.Errorf("paced atomic path lost content: got %d bytes, want %d", joined.Len(), len(prompt)+1) + } +} + // Sanity: a custom config with SubmitDelay set but no Claude-specific flags // also splits — the field is the lever, not the Claude struct identity. func TestPlanPassthroughStdinChunks_SubmitDelayDrivesSplit(t *testing.T) { diff --git a/apps/backend/internal/agent/agents/tui_agent.go b/apps/backend/internal/agent/agents/tui_agent.go index d29e0ea47d..6c0b6f0894 100644 --- a/apps/backend/internal/agent/agents/tui_agent.go +++ b/apps/backend/internal/agent/agents/tui_agent.go @@ -36,6 +36,9 @@ type TUIAgentConfig struct { // an MCP client at all. Resolved from the user's stored strategy key; see // mcpconfig.StrategyByKey for why it is chosen rather than inferred. MCPStrategy mcpconfig.PassthroughMCPStrategy + // DisableBracketedPaste selects the paced unframed delivery path for a + // terminal that does not accept bracketed-paste delimiters. + DisableBracketedPaste bool } // TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools. @@ -74,24 +77,23 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent { a := &TUIAgent{ StandardPassthrough: StandardPassthrough{ Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: cfg.Desc, - PassthroughCmd: NewCommand(cmdArgs...), - ModelFlag: cfg.ModelFlag, - IdleTimeout: cfg.IdleTimeout, - BufferMaxBytes: cfg.BufferMax, - WaitForTerminal: cfg.WaitForTerm, - MCPStrategy: cfg.MCPStrategy, + Supported: true, + Label: "CLI Passthrough", + Description: cfg.Desc, + PassthroughCmd: NewCommand(cmdArgs...), + ModelFlag: cfg.ModelFlag, + IdleTimeout: cfg.IdleTimeout, + BufferMaxBytes: cfg.BufferMax, + WaitForTerminal: cfg.WaitForTerm, + MCPStrategy: cfg.MCPStrategy, + DisableBracketedPaste: cfg.DisableBracketedPaste, // Ink-based TUIs (Claude Code and similar) coalesce multi-byte // stdin reads into a paste burst, absorbing a trailing "\r" into - // the pasted content instead of dispatching Enter, and enable - // bracketed-paste mode so ESC[200~…ESC[201~ delimiters break - // input. Send prompt bytes verbatim and split the submit byte - // into a discrete keystroke so programmatic PTY prompts submit. - // Matches the built-in Claude passthrough agent. - DisableBracketedPaste: true, - SubmitDelay: 150 * time.Millisecond, + // the pasted content instead of dispatching Enter. The submit + // byte is therefore a separate delayed keystroke, and the body + // travels as a bracketed paste so it arrives whole at any + // length. Matches the built-in Claude passthrough agent. + SubmitDelay: 150 * time.Millisecond, }, }, cfg: cfg, diff --git a/apps/backend/internal/agent/agents/tui_agent_test.go b/apps/backend/internal/agent/agents/tui_agent_test.go index 0be583c013..3fe2dd19e2 100644 --- a/apps/backend/internal/agent/agents/tui_agent_test.go +++ b/apps/backend/internal/agent/agents/tui_agent_test.go @@ -9,12 +9,12 @@ import ( ) // Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude -// Code. Ink coalesces multi-byte stdin reads into a paste burst and absorbs a -// trailing "\r" into the pasted content instead of dispatching Enter, and it -// enables bracketed-paste mode so ESC[200~…ESC[201~ delimiters break input. -// The built-in Claude passthrough agent works around both; custom TUI agents -// must inherit the same defaults or programmatic PTY prompts (peer messaging, -// queued-message drain, workflow auto-start) land in the input box unsubmitted. +// Code. Ink absorbs a trailing "\r" into a pasted burst instead of dispatching +// Enter, so the submit byte needs its own delayed write. Bracketed-paste +// framing stays on: without it a body larger than one terminal read reaches the +// TUI as a burst it silently drops whole reads from. Custom TUI agents must +// inherit both defaults or programmatic PTY prompts (peer messaging, +// queued-message drain, workflow auto-start) arrive truncated or unsubmitted. func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) { a := NewTUIAgent(TUIAgentConfig{ AgentID: "custom-tui", @@ -24,8 +24,8 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) { }) pt := a.PassthroughConfig() - if !pt.DisableBracketedPaste { - t.Error("DisableBracketedPaste = false, want true (send prompt bytes verbatim; Ink breaks on bracketed-paste delimiters)") + if pt.DisableBracketedPaste { + t.Error("DisableBracketedPaste = true, want false (framing is what carries a body past one terminal read)") } if pt.SubmitDelay != 150*time.Millisecond { t.Errorf("SubmitDelay = %v, want 150ms (split submit byte into a discrete keystroke so Ink does not absorb it into a paste burst)", pt.SubmitDelay) diff --git a/apps/backend/internal/agent/registry/custom_tui.go b/apps/backend/internal/agent/registry/custom_tui.go index c3bf6e3124..f581797879 100644 --- a/apps/backend/internal/agent/registry/custom_tui.go +++ b/apps/backend/internal/agent/registry/custom_tui.go @@ -37,6 +37,9 @@ type CustomTUIAgentSpec struct { // Protocol selects the runtime kandev drives the command with. Empty means // terminal passthrough. Protocol CustomAgentProtocol + // DisableBracketedPaste selects paced unframed delivery for terminal TUIs + // that do not accept bracketed-paste delimiters. + DisableBracketedPaste bool } // ErrUnknownMCPStrategy is returned when a spec names a strategy key that no @@ -89,14 +92,15 @@ func buildCustomAgent(spec CustomTUIAgentSpec) (agents.Agent, error) { return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey) } return agents.NewTUIAgent(agents.TUIAgentConfig{ - AgentID: spec.Slug, - AgentName: spec.Slug, - Command: binary, - Desc: spec.Description, - Display: spec.DisplayName, - WaitForTerm: true, - CommandArgs: args, - MCPStrategy: strategy, + AgentID: spec.Slug, + AgentName: spec.Slug, + Command: binary, + Desc: spec.Description, + Display: spec.DisplayName, + WaitForTerm: true, + CommandArgs: args, + MCPStrategy: strategy, + DisableBracketedPaste: spec.DisableBracketedPaste, }), nil case CustomAgentProtocolACP: if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone { diff --git a/apps/backend/internal/agent/registry/custom_tui_test.go b/apps/backend/internal/agent/registry/custom_tui_test.go index 05e5f4666f..045cac9c06 100644 --- a/apps/backend/internal/agent/registry/custom_tui_test.go +++ b/apps/backend/internal/agent/registry/custom_tui_test.go @@ -109,6 +109,33 @@ func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) { } } +func TestRegisterCustomTUIAgent_DisableBracketedPaste(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{ + Slug: "raw-tui", + DisplayName: "Raw TUI", + Command: "raw-tui", + DisableBracketedPaste: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ag, ok := reg.Get("raw-tui") + if !ok { + t.Fatal("expected agent to be registered") + } + pt, ok := ag.(agents.PassthroughAgent) + if !ok { + t.Fatal("custom terminal agent does not implement PassthroughAgent") + } + if !pt.PassthroughConfig().DisableBracketedPaste { + t.Error("DisableBracketedPaste = false, want true") + } +} + func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 1d690f0623..2cdaa40a8d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -1721,8 +1721,12 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio zap.Error(err)) } for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) { - if chunk.DelayBefore > 0 { - time.Sleep(chunk.DelayBefore) + if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil { + m.logger.Warn("autoInjectInitialPrompt delay interrupted", + zap.String("execution_id", execution.ID), + zap.String("process_id", processID), + zap.Error(err)) + return } if err := runner.WriteStdin(processID, chunk.Data); err != nil { m.logger.Warn("autoInjectInitialPrompt write failed", @@ -1738,6 +1742,20 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio zap.Int("description_len", len(description))) } +func waitPassthroughChunkDelay(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + // ResolvePassthroughConfig returns the PassthroughConfig for a session's agent. // Used by callers outside this package (e.g. orchestrator) that need the submit // sequence to write to PTY stdin. diff --git a/apps/backend/internal/agent/settings/controller/custom_tui.go b/apps/backend/internal/agent/settings/controller/custom_tui.go index 89699f8095..172aea8f8a 100644 --- a/apps/backend/internal/agent/settings/controller/custom_tui.go +++ b/apps/backend/internal/agent/settings/controller/custom_tui.go @@ -38,6 +38,9 @@ type CreateCustomTUIAgentRequest struct { // Protocol is the runtime kandev drives the command with // (registry.CustomAgentProtocol*). Empty means terminal passthrough. Protocol string + // DisableBracketedPaste selects paced unframed delivery for terminal TUIs + // that do not accept bracketed-paste delimiters. + DisableBracketedPaste bool } // validateCustomAgentProtocol rejects a protocol/strategy pair the registry @@ -92,14 +95,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT // Register in the in-memory registry if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{ - Slug: slug, - DisplayName: req.DisplayName, - Command: req.Command, - Description: req.Description, - Model: req.Model, - CommandArgs: req.CommandArgs, - MCPStrategyKey: req.MCPStrategy, - Protocol: protocol, + Slug: slug, + DisplayName: req.DisplayName, + Command: req.Command, + Description: req.Description, + Model: req.Model, + CommandArgs: req.CommandArgs, + MCPStrategyKey: req.MCPStrategy, + Protocol: protocol, + DisableBracketedPaste: req.DisableBracketedPaste, }); regErr != nil { return nil, fmt.Errorf("failed to register agent: %w", regErr) } @@ -107,14 +111,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT // Persist to DB acp := protocol == registry.CustomAgentProtocolACP tuiConfig := &models.TUIConfigJSON{ - Command: req.Command, - DisplayName: req.DisplayName, - Model: req.Model, - Description: req.Description, - CommandArgs: req.CommandArgs, - WaitForTerminal: true, - MCPStrategy: req.MCPStrategy, - Protocol: req.Protocol, + Command: req.Command, + DisplayName: req.DisplayName, + Model: req.Model, + Description: req.Description, + CommandArgs: req.CommandArgs, + WaitForTerminal: true, + MCPStrategy: req.MCPStrategy, + Protocol: req.Protocol, + DisableBracketedPaste: req.DisableBracketedPaste, } agent := &models.Agent{ Name: slug, @@ -251,14 +256,15 @@ func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error { // silently miss the other. func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec { return registry.CustomTUIAgentSpec{ - Slug: name, - DisplayName: cfg.DisplayName, - Command: cfg.Command, - Description: cfg.Description, - Model: cfg.Model, - CommandArgs: cfg.CommandArgs, - MCPStrategyKey: cfg.MCPStrategy, - Protocol: registry.CustomAgentProtocol(cfg.Protocol), + Slug: name, + DisplayName: cfg.DisplayName, + Command: cfg.Command, + Description: cfg.Description, + Model: cfg.Model, + CommandArgs: cfg.CommandArgs, + MCPStrategyKey: cfg.MCPStrategy, + Protocol: registry.CustomAgentProtocol(cfg.Protocol), + DisableBracketedPaste: cfg.DisableBracketedPaste, } } diff --git a/apps/backend/internal/agent/settings/controller/custom_tui_test.go b/apps/backend/internal/agent/settings/controller/custom_tui_test.go index 10d8d7f955..1fbbbbfc7a 100644 --- a/apps/backend/internal/agent/settings/controller/custom_tui_test.go +++ b/apps/backend/internal/agent/settings/controller/custom_tui_test.go @@ -82,6 +82,34 @@ func TestCreateCustomTUIAgent_CommandArgsPersisted(t *testing.T) { } } +func TestCreateCustomTUIAgent_DisableBracketedPasteReachesRuntimeAndStorage(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Raw TUI", + Command: "raw-tui", + DisableBracketedPaste: true, + }); err != nil { + t.Fatalf("CreateCustomTUIAgent: %v", err) + } + + ag, ok := c.agentRegistry.Get("raw-tui") + if !ok { + t.Fatal("custom agent was not registered") + } + pt, ok := ag.(agents.PassthroughAgent) + if !ok { + t.Fatal("custom agent is not a passthrough agent") + } + if !pt.PassthroughConfig().DisableBracketedPaste { + t.Error("runtime DisableBracketedPaste = false, want true") + } + if !st.byName["raw-tui"].TUIConfig.DisableBracketedPaste { + t.Error("stored DisableBracketedPaste = false, want true") + } +} + // TestCreateCustomTUIAgent_NoCommandArgs keeps the existing behaviour intact // when the caller omits the field. func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) { diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index ce060fb338..6d350f2404 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -1218,14 +1218,15 @@ func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentPro } if agent.TUIConfig != nil { result.TUIConfig = &dto.TUIConfigDTO{ - Command: agent.TUIConfig.Command, - DisplayName: agent.TUIConfig.DisplayName, - Model: agent.TUIConfig.Model, - Description: agent.TUIConfig.Description, - CommandArgs: agent.TUIConfig.CommandArgs, - WaitForTerminal: agent.TUIConfig.WaitForTerminal, - MCPStrategy: agent.TUIConfig.MCPStrategy, - Protocol: agent.TUIConfig.Protocol, + Command: agent.TUIConfig.Command, + DisplayName: agent.TUIConfig.DisplayName, + Model: agent.TUIConfig.Model, + Description: agent.TUIConfig.Description, + CommandArgs: agent.TUIConfig.CommandArgs, + WaitForTerminal: agent.TUIConfig.WaitForTerminal, + MCPStrategy: agent.TUIConfig.MCPStrategy, + Protocol: agent.TUIConfig.Protocol, + DisableBracketedPaste: agent.TUIConfig.DisableBracketedPaste, } } if c.agentRegistry != nil { diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 3798249378..917fa38bcf 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -146,6 +146,9 @@ type TUIConfigDTO struct { // Protocol is the runtime kandev drives the command with ("" = terminal // passthrough, "acp" = ACP on stdin/stdout). Protocol string `json:"protocol,omitempty"` + // DisableBracketedPaste selects paced unframed delivery for terminal TUIs + // that do not accept bracketed-paste delimiters. + DisableBracketedPaste bool `json:"disable_bracketed_paste,omitempty"` } type AgentDTO struct { diff --git a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go index 38f93af89d..bba6fe5d18 100644 --- a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go +++ b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go @@ -26,17 +26,21 @@ type createCustomTUIAgentRequest struct { // Protocol is the runtime kandev drives the command with: empty (the // default) for terminal passthrough, "acp" for ACP on stdin/stdout. Protocol string `json:"protocol"` + // DisableBracketedPaste selects paced unframed delivery for terminal TUIs + // that do not accept bracketed-paste delimiters. + DisableBracketedPaste bool `json:"disable_bracketed_paste"` } func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest { return controller.CreateCustomTUIAgentRequest{ - DisplayName: r.DisplayName, - Model: r.Model, - Command: r.Command, - Description: r.Description, - CommandArgs: r.CommandArgs, - MCPStrategy: r.MCPStrategy, - Protocol: r.Protocol, + DisplayName: r.DisplayName, + Model: r.Model, + Command: r.Command, + Description: r.Description, + CommandArgs: r.CommandArgs, + MCPStrategy: r.MCPStrategy, + Protocol: r.Protocol, + DisableBracketedPaste: r.DisableBracketedPaste, } } diff --git a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers_test.go b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers_test.go index 22d22830af..c0ef858c97 100644 --- a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers_test.go @@ -14,7 +14,8 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) { payload := `{ "display_name": "Spaced Args", "command": "my-cli", - "command_args": ["--system-prompt", "you are a helpful agent"] + "command_args": ["--system-prompt", "you are a helpful agent"], + "disable_bracketed_paste": true }` var body createCustomTUIAgentRequest @@ -30,6 +31,9 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) { if req.DisplayName != "Spaced Args" || req.Command != "my-cli" { t.Errorf("unexpected mapping: %+v", req) } + if !req.DisableBracketedPaste { + t.Error("DisableBracketedPaste = false, want true") + } } // TestCreateCustomTUIAgentRequest_OmittedCommandArgs keeps the field optional. diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 92f54e3606..c819018eca 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -50,6 +50,9 @@ type TUIConfigJSON struct { // (registry.CustomAgentProtocol*). Empty — the value every row written // before this field existed decodes to — means terminal passthrough. Protocol string `json:"protocol,omitempty"` + // DisableBracketedPaste selects paced unframed delivery for terminal TUIs + // that do not accept bracketed-paste delimiters. + DisableBracketedPaste bool `json:"disable_bracketed_paste,omitempty"` } type AgentProfile struct { diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 9e65b5dc3f..109d492e70 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -2516,6 +2516,25 @@ func TestDeliverPassthroughPrompt(t *testing.T) { } }) + t.Run("refuses the prompt when config resolution fails", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + configErr := errors.New("agent definition is unavailable") + agentMgr := &mockAgentManager{ + isPassthrough: true, + passthroughConfigErr: configErr, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + err := svc.writePassthroughPrompt(context.Background(), "s1", strings.Repeat("long prompt ", 100)) + if !errors.Is(err, configErr) { + t.Fatalf("writePassthroughPrompt error = %v, want config error", err) + } + if got := len(agentMgr.passthroughStdinCalls); got != 0 { + t.Fatalf("stdin calls after config failure = %d, want 0", got) + } + }) + t.Run("cancellation interrupts submit delay", func(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index ec2d653f67..29b0f50cf5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -5592,8 +5592,8 @@ type passthroughRunningPreparer interface { // deliverPassthroughPrompt writes a prompt to PTY stdin and marks the session as running. // Uses the per-agent PlanPassthroughStdinChunks so Claude's inter-chunk SubmitDelay is // honored here too (queued / workflow-auto-start path); other agents stay on the single -// atomic write. Falls back to the simple "\r" append if config resolution fails so a -// transient lookup error never silently swallows the prompt. +// atomic write. Config resolution is required: an unknown agent contract must not fall +// back to one unframed write, because a long prompt can be silently truncated. // // Callers that already hold the per-session cancellation guard must use // PreparePassthroughRunning + writePassthroughPrompt instead (see handleAgentReady); this @@ -5618,15 +5618,10 @@ func (s *Service) deliverPassthroughPrompt(ctx context.Context, sessionID, conte func (s *Service) writePassthroughPrompt(ctx context.Context, sessionID, content string) error { pt, cfgErr := s.agentManager.ResolvePassthroughConfig(ctx, sessionID) if cfgErr != nil { - s.logger.Warn("failed to resolve passthrough config, falling back to \\r submit", + s.logger.Warn("failed to resolve passthrough config; refusing unsafe prompt write", zap.String("session_id", sessionID), zap.Error(cfgErr)) - } - if cfgErr != nil { - if err := s.agentManager.WritePassthroughStdin(ctx, sessionID, content+"\r"); err != nil { - return fmt.Errorf("write to passthrough stdin: %w", err) - } - return nil + return fmt.Errorf("resolve passthrough config: %w", cfgErr) } for _, chunk := range agents.PlanPassthroughStdinChunks(content, pt) { if chunk.DelayBefore > 0 { diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index 1c488aac34..02d9df535c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -724,8 +724,8 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session zap.Error(err)) } for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) { - if chunk.DelayBefore > 0 { - time.Sleep(chunk.DelayBefore) + if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil { + return nil, err } if err := e.agentManager.WritePassthroughStdin(ctx, sessionID, chunk.Data); err != nil { return nil, fmt.Errorf("failed to write to passthrough stdin: %w", err) @@ -734,6 +734,20 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session return &PromptResult{StopReason: stopReasonPassthrough}, nil } +func waitPassthroughChunkDelay(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) { if len(attachments) == 0 { return prompt, nil diff --git a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go index 91370ec626..fb426d08b6 100644 --- a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go @@ -588,6 +588,37 @@ func TestExecutor_Prompt_PassthroughSubmitDelaySplitsWrites(t *testing.T) { } } +func TestExecutor_Prompt_PassthroughCancellationStopsDelayedSubmit(t *testing.T) { + repo := newMockRepository() + ctx, cancel := context.WithCancel(context.Background()) + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + resolvePassthroughConfigFunc: func(_ context.Context, _ string) (agents.PassthroughConfig, error) { + return agents.PassthroughConfig{ + Supported: true, + SubmitSequence: "\r", + DisableBracketedPaste: true, + SubmitDelay: time.Second, + }, nil + }, + writePassthroughStdinFunc: func(_ context.Context, _ string, data string) error { + if data == "hello agent" { + cancel() + } + return nil + }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + exec := newTestExecutor(t, agentManager, repo) + + if _, err := exec.Prompt(ctx, "task-1", "sess-1", "hello agent", nil, false); !errors.Is(err, context.Canceled) { + t.Fatalf("Prompt error = %v, want context cancellation", err) + } + if got := len(agentManager.writePassthroughStdinCalls); got != 1 { + t.Fatalf("stdin calls after cancellation = %d, want 1", got) + } +} + func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) { repo := newMockRepository() agentManager := &mockAgentManager{ diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index d57b2357ba..5c8a55041a 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -347,6 +347,7 @@ export async function createCustomTUIAgent( description?: string; mcp_strategy?: string; protocol?: string; + disable_bracketed_paste?: boolean; }, options?: ApiRequestOptions, ): Promise { diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 14e264900c..c924fcf469 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -47,6 +47,8 @@ export type TUIConfig = { * calls, models, and modes. */ protocol?: CustomAgentProtocol; + /** Use paced unframed writes for TUIs that reject bracketed-paste markers. */ + disable_bracketed_paste?: boolean; }; /** One selectable MCP injection mechanism, served by the backend. */ diff --git a/docs/plans/passthrough-prompt-delivery-integrity/plan.md b/docs/plans/passthrough-prompt-delivery-integrity/plan.md new file mode 100644 index 0000000000..989d4342e5 --- /dev/null +++ b/docs/plans/passthrough-prompt-delivery-integrity/plan.md @@ -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. diff --git a/docs/plans/passthrough-prompt-delivery-integrity/task-01-frame-passthrough-prompt-body.md b/docs/plans/passthrough-prompt-delivery-integrity/task-01-frame-passthrough-prompt-body.md new file mode 100644 index 0000000000..cc2b39c5de --- /dev/null +++ b/docs/plans/passthrough-prompt-delivery-integrity/task-01-frame-passthrough-prompt-body.md @@ -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. diff --git a/docs/plans/passthrough-prompt-delivery-integrity/task-02-pace-unframed-passthrough-writes.md b/docs/plans/passthrough-prompt-delivery-integrity/task-02-pace-unframed-passthrough-writes.md new file mode 100644 index 0000000000..d0653051fb --- /dev/null +++ b/docs/plans/passthrough-prompt-delivery-integrity/task-02-pace-unframed-passthrough-writes.md @@ -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). diff --git a/docs/specs/cli/requirements/cli-mode-parity.md b/docs/specs/cli/requirements/cli-mode-parity.md index 8bb3216280..f794ce7468 100644 --- a/docs/specs/cli/requirements/cli-mode-parity.md +++ b/docs/specs/cli/requirements/cli-mode-parity.md @@ -76,7 +76,7 @@ When a passthrough session starts for a task that has a description: No per-agent pattern matchers. The existing idle window is the only readiness signal. If an agent's CLI is unusual enough that an idle window misfires (writes a banner, then waits 5 seconds, then prompts), we make the idle window per-agent-configurable in `PassthroughConfig` (already exists as `IdleTimeout`). No new detection machinery. -For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata, sets `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route. +For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata and uses a 150 ms `SubmitDelay`. The prompt body uses bracketed-paste framing, then the submit carriage return is sent as a separate delayed write. This keeps the submit byte out of the Ink paste burst. Custom terminal agents can set `DisableBracketedPaste: true` when they do not accept bracketed-paste delimiters; Kandev then uses bounded, paced unframed writes. The pacing is an empirical fallback because PTY writes do not provide a receipt from the TUI. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route. ### Follow-up prompts via PTY stdin diff --git a/docs/specs/cli/requirements/passthrough-prompt-delivery.md b/docs/specs/cli/requirements/passthrough-prompt-delivery.md new file mode 100644 index 0000000000..bbc87bd12e --- /dev/null +++ b/docs/specs/cli/requirements/passthrough-prompt-delivery.md @@ -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. diff --git a/docs/specs/cli/system-design/passthrough-prompt-delivery.md b/docs/specs/cli/system-design/passthrough-prompt-delivery.md new file mode 100644 index 0000000000..a1a6673b38 --- /dev/null +++ b/docs/specs/cli/system-design/passthrough-prompt-delivery.md @@ -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.