From a35dd6d22ecfa5049bad06f07bfbe236f4f536a0 Mon Sep 17 00:00:00 2001 From: Nicolas Lenepveu Date: Fri, 7 Aug 2026 16:24:58 +0200 Subject: [PATCH] fix(passthrough): deliver prompt to custom TUI agents via stdin (#2335) --- apps/backend/internal/agent/agents/agent.go | 6 ++- .../runtime/lifecycle/manager_passthrough.go | 42 ++++++++++++------- .../manager_passthrough_autoinject_test.go | 32 +++++++++++++- .../manager_passthrough_command_test.go | 29 +++++++++---- .../server/process/interactive_lifecycle.go | 4 +- docs/specs/cli-mode-parity/spec.md | 16 +++---- 6 files changed, 92 insertions(+), 37 deletions(-) diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 53785432a3..55aa324722 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -336,8 +336,10 @@ type PassthroughConfig struct { // without touching the user's global config. Nil means no MCP injection. MCPStrategy mcpconfig.PassthroughMCPStrategy WaitForTerminal bool - // AutoInjectPrompt enables writing the task description to the PTY stdin - // after the first idle window. Default false preserves today's behavior. + // AutoInjectPrompt is retained in discovered passthrough metadata for + // compatibility. Initial prompt delivery is selected by PromptFlag: when it + // is empty, the lifecycle manager writes the task description to PTY stdin. + // The value no longer gates that delivery. AutoInjectPrompt bool // SubmitSequence is appended after the prompt text when auto-injecting // and when routing chat-compose messages to the PTY. "\r" for most TUIs. diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 524fb3dd05..04e8df4f6c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -218,13 +218,23 @@ func (m *Manager) resolvePassthroughAgent(ctx context.Context, execution *AgentE } // promptForPassthroughCommand returns the prompt that should be passed to -// BuildPassthroughCommand. When the agent uses idle-based auto-inject and has -// no PromptFlag, the prompt would otherwise be appended as a positional arg -// (putting TUIs like Claude into non-interactive `-p` mode and exiting before -// auto-inject fires). In that case we return "" so the prompt is delivered via -// PTY stdin in autoInjectInitialPrompt. +// BuildPassthroughCommand. +// +// When the agent has a PromptFlag, the prompt rides that flag and is returned +// here. When it does not, the prompt is suppressed: BuildPassthroughCommand +// would otherwise append it as a positional argument, which interactive TUIs +// (claude, codex, fuelclaude, …) ignore or misinterpret — e.g. `zsh -ic +// "fuelclaude --model opus" ""` drops the prompt as an unreferenced +// positional and the agent starts at an empty prompt. In the no-flag case the +// prompt is delivered instead via PTY stdin in autoInjectInitialPrompt. +// +// AutoInjectPrompt alone no longer gates suppression: a custom TUI agent with +// neither a PromptFlag nor AutoInjectPrompt (the default for agents built from +// a tui_config) would otherwise have no delivery path at all, so suppression is +// keyed on the absence of a PromptFlag, which is the actual "can't carry the +// prompt on the CLI" condition. func promptForPassthroughCommand(pt agents.PassthroughConfig, taskDescription string) string { - if pt.AutoInjectPrompt && pt.PromptFlag.IsEmpty() { + if pt.PromptFlag.IsEmpty() { return "" } return taskDescription @@ -1415,10 +1425,10 @@ type passthroughRunner interface { WriteStdin(processID string, data string) error } -// autoInjectInitialPrompt writes the task description to the PTY stdin once -// the agent is idle (ready for input). Opt-in per agent via PassthroughConfig. -// Called from startPassthroughSession and attemptResumeFallback only — never -// from ResumePassthroughSession (would duplicate the prompt in agent history). +// autoInjectInitialPrompt writes the task description to PTY stdin once a +// passthrough agent without a PromptFlag is idle (ready for input). Called from +// startPassthroughSession and attemptResumeFallback only — never from +// ResumePassthroughSession (would duplicate the prompt in agent history). func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig) { runner := m.GetInteractiveRunner() if runner == nil { @@ -1430,15 +1440,19 @@ func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.P // autoInjectInitialPromptWith is the testable inner of autoInjectInitialPrompt, // taking a runner seam so unit tests can avoid spawning a real PTY. func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, execution *AgentExecution, pt agents.PassthroughConfig) { - if !pt.AutoInjectPrompt { - return - } if !pt.PromptFlag.IsEmpty() { - // The agent already received the prompt as a CLI flag. + // The agent already received the prompt as a CLI flag — no stdin + // delivery. This mirrors promptForPassthroughCommand, which only + // passes the prompt to BuildPassthroughCommand when a PromptFlag + // exists; the two no-flag cases (built-in AutoInjectPrompt agents + // and custom TUI agents) both land here for stdin delivery. return } description := getTaskDescriptionFromMetadata(execution) if description == "" { + // Nothing to inject (e.g. a non-LLM TUI tool with no task + // description). Generic passthrough tools launched without a task + // take this exit and never receive spurious stdin. return } processID := execution.PassthroughProcessID diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_autoinject_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_autoinject_test.go index 518709f3d7..c333ae8bd2 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_autoinject_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_autoinject_test.go @@ -70,17 +70,45 @@ func newAutoInjectExecution(description string) *AgentExecution { return exec } +// TestAutoInject_disabled_does_nothing covers the only remaining no-write +// case under the new gating: a generic passthrough tool with no PromptFlag and +// no task description must not receive spurious stdin. (A non-LLM TUI like k9s +// launched without a task lands here.) AutoInjectPrompt is irrelevant now — +// delivery is keyed on the absence of a PromptFlag, and a description is what +// makes stdin delivery fire. func TestAutoInject_disabled_does_nothing(t *testing.T) { mgr := newTestManager(t) runner := &fakePassthroughRunner{} + mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution(""), agents.PassthroughConfig{ + AutoInjectPrompt: false, + SubmitSequence: "\r", + }) + + if runner.writeCalled { + t.Fatalf("expected no stdin write when task description is empty, got data=%q", runner.writtenData) + } +} + +// TestAutoInject_writes_without_auto_inject_flag is the custom-TUI regression: +// an agent with neither AutoInjectPrompt nor a PromptFlag (the default for +// agents built from a tui_config, e.g. fuelclaude-opus) must still receive its +// task description via PTY stdin. Before the fix the prompt was appended as a +// positional arg that zsh -ic dropped, so the agent started at an empty prompt. +func TestAutoInject_writes_without_auto_inject_flag(t *testing.T) { + mgr := newTestManager(t) + runner := &fakePassthroughRunner{} + mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("do a thing"), agents.PassthroughConfig{ AutoInjectPrompt: false, SubmitSequence: "\r", }) - if runner.writeCalled { - t.Fatalf("expected no stdin write when AutoInjectPrompt is false, got data=%q", runner.writtenData) + if !runner.writeCalled { + t.Fatalf("expected WriteStdin to be called for a no-flag custom TUI agent") + } + if runner.writtenData != "do a thing\r" { + t.Errorf("WriteStdin data = %q, want %q", runner.writtenData, "do a thing\r") } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_command_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_command_test.go index c3690131e0..67e82c0cb5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_command_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_command_test.go @@ -7,10 +7,13 @@ import ( ) // TestPromptForPassthroughCommand asserts the gating used by passthroughAgentCommand: -// AutoInjectPrompt + empty PromptFlag suppresses the positional prompt arg, so the -// description is delivered exclusively via PTY stdin in autoInjectInitialPrompt. -// Without this guard, BuildPassthroughCommand would append the description as a -// positional arg and put Claude (and similar TUIs) into non-interactive `-p` mode. +// a prompt is passed to BuildPassthroughCommand only when the agent declares a +// PromptFlag. Without one, the prompt is suppressed (returned as "") so it is +// delivered exclusively via PTY stdin in autoInjectInitialPrompt — appending it +// as a positional arg instead drops it for interactive TUIs (e.g. zsh -ic +// "fuelclaude --model opus" "" ignores the positional), which is the +// bug custom TUI agents hit. AutoInjectPrompt no longer gates suppression: the +// no-PromptFlag condition is what actually determines CLI-vs-stdin delivery. func TestPromptForPassthroughCommand(t *testing.T) { tests := []struct { name string @@ -19,22 +22,30 @@ func TestPromptForPassthroughCommand(t *testing.T) { want string }{ { - name: "auto-inject suppresses prompt arg", + name: "no prompt flag suppresses prompt arg (auto-inject agent)", pt: agents.PassthroughConfig{AutoInjectPrompt: true}, desc: "refactor cron handler", want: "", }, { - name: "auto-inject off keeps prompt", + name: "no prompt flag suppresses prompt arg (custom TUI agent)", pt: agents.PassthroughConfig{AutoInjectPrompt: false}, desc: "refactor cron handler", + want: "", + }, + { + name: "explicit PromptFlag keeps prompt — flag delivery wins", + pt: agents.PassthroughConfig{ + AutoInjectPrompt: true, + PromptFlag: agents.NewParam("--prompt", "{prompt}"), + }, + desc: "refactor cron handler", want: "refactor cron handler", }, { - name: "auto-inject with explicit PromptFlag keeps prompt — flag delivery wins", + name: "explicit PromptFlag without auto-inject keeps prompt", pt: agents.PassthroughConfig{ - AutoInjectPrompt: true, - PromptFlag: agents.NewParam("--prompt", "{prompt}"), + PromptFlag: agents.NewParam("--prompt", "{prompt}"), }, desc: "refactor cron handler", want: "refactor cron handler", diff --git a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go index c67753c8cb..e45b5d57b4 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go +++ b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go @@ -66,8 +66,8 @@ type interactiveProcess struct { // firstIdleCh is closed the first time the idle detector fires for this // process — i.e. the CLI has finished its startup output and is ready for - // input. Used by the lifecycle manager to auto-inject the task description - // at the right moment when AutoInjectPrompt is enabled. + // input. Used by the lifecycle manager to inject initial task descriptions + // for passthrough agents without a PromptFlag. firstIdleOnce sync.Once firstIdleCh chan struct{} } diff --git a/docs/specs/cli-mode-parity/spec.md b/docs/specs/cli-mode-parity/spec.md index d6c2a2e7ba..0593ade0ba 100644 --- a/docs/specs/cli-mode-parity/spec.md +++ b/docs/specs/cli-mode-parity/spec.md @@ -31,12 +31,12 @@ The task-create dialog's prompt textarea is **enabled** when a CLI/passthrough-c When a passthrough session starts for a task that has a description: 1. Kandev launches the PTY as today. -2. After the existing **idle detector** in `InteractiveRunner` fires for the first time (i.e. the CLI has stopped emitting output and is presumed to be at its input prompt), kandev writes the task description plus a configurable **submit sequence** to the PTY's stdin. -3. Auto-injection is opt-in per agent via a new `PassthroughConfig.AutoInjectPrompt` flag (default false). Agents that already use `PromptFlag` (headless one-shot mode) are unaffected — their prompt is already on the CLI before launch. +2. After the existing **idle detector** in `InteractiveRunner` fires for the first time (i.e. the CLI has stopped emitting output and is presumed to be at its input prompt), kandev writes the task description plus a configurable **submit sequence** to the PTY's stdin when the agent has no `PromptFlag`. +3. Agents that use `PromptFlag` (headless one-shot mode) are unaffected — their prompt is already on the CLI before launch. `PassthroughConfig.AutoInjectPrompt` remains in discovered metadata for compatibility, but the presence of `PromptFlag` is the runtime delivery boundary. 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` sets `AutoInjectPrompt: true`, `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 use bracketed-paste delimiters for multi-line prompts and stay default-off for auto-inject unless configured. +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. ### Follow-up prompts via PTY stdin @@ -64,7 +64,7 @@ Users can still press Ctrl-C directly inside the xterm terminal. A dedicated too - THEN the prompt textarea accepts input (today it is disabled / hidden) ### Prompt injection on fresh start -- GIVEN a CLI-mode task whose agent has `AutoInjectPrompt: true` and a non-empty description +- GIVEN a CLI-mode task whose agent has no `PromptFlag` and a non-empty description - WHEN the task starts - THEN the PTY launches, the idle detector fires once after the CLI settles, and the task description is written to stdin followed by `SubmitSequence` - AND the description appears in the terminal output @@ -96,10 +96,10 @@ Users can still press Ctrl-C directly inside the xterm terminal. A dedicated too - THEN `\x03` is written to the PTY - AND DB reconciliation still completes so the session unsticks -### Agent without AutoInjectPrompt -- GIVEN a passthrough-capable agent with `AutoInjectPrompt: false` +### Passthrough agent without PromptFlag +- GIVEN a passthrough-capable agent with no `PromptFlag`, regardless of its compatibility metadata - WHEN a task starts -- THEN no stdin write happens (today's behavior preserved); the user pastes their prompt manually +- THEN the task description is written to stdin after the first idle window ## Kandev toolbar above the passthrough terminal @@ -167,7 +167,7 @@ Pressing Ctrl-C inside the passthrough terminal writes `\x03` to PTY stdin. A fu ## Follow-ups - **Stop button overlay in `PassthroughTerminal`.** Small affordance that calls the existing cancel route. Today users press Ctrl-C inside xterm directly. -- **AutoInjectPrompt on other passthrough agents** (Codex CLI, OpenCode TUI, etc.) — only Claude is opted in for v1. Adding others requires verifying their submit sequence and idle behavior. +- **Agent-specific prompt-injection tuning** — passthrough agents without a `PromptFlag` use the idle-based stdin route; adding agent-specific submit behavior requires verifying its submit sequence and idle behavior. ## Open questions