From a1a1d64c2c5428559af7775f9ca21c0f9cad3d5b Mon Sep 17 00:00:00 2001 From: ANonABento Date: Sat, 22 Aug 2026 13:26:30 -0400 Subject: [PATCH] fix(interactive): the trigger prompt was never actually submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both in the injection step, both found by watching a real claude TUI in its tmux pane. The prompt was injected as a multiline payload. `tmux send-keys -l` sends the text literally, newlines included. That does not submit each line — it leaves the TUI in multi-line input, and the `Enter` that follows adds another line instead of sending. The default trigger prompt is `"\n\nSee .task.md for full spec."`, so this was not an edge case: interactive triggers had never submitted their own prompt. The pane sat with the text visible and unsent until the 2-hour timeout, which reads as a hung agent rather than a delivery bug. `flatten_for_injection` collapses the prompt to one line. Nothing is lost — the detail lives in `.task.md` and `.agent.md`, and the prompt is only a pointer to them. The existing doc comment on `append_sentinel_to_prompt` already said the payload must stay newline-free, but the rule was only ever applied to the sentinel suffix, never to the prompt it was appended to. Its explanation of what a newline does was also wrong (it does not submit early); corrected to match what the pane actually shows. Enter was sent too soon after the text. Even flattened, the prompt stayed unsent: these TUIs ingest pasted text asynchronously and an immediate `Enter` is dropped. Confirmed by hand — the same pane submitted instantly when Enter was sent a moment later. A 600ms settle now separates the two. Generous on purpose: the cost of waiting is a fraction of a second on a run measured in minutes, and the cost of being early is a trigger that hangs for two hours. Verified end to end on a real column: prompt submitted, agent read `.agent.md` and followed it, fixed the planted bug, wrote its proof file, and emitted `<<<KAITENCODE_DONE:…>>>`, which the watcher detected and persisted to `agent_done_signaled_at`. The task deliberately did not advance — interactive completion is advisory, since the session stays alive for the human. `.task.md` and `.agent.md` were correctly absent from `git status`, confirming the exclude fix holds on this path too. --- .tickets/_docs/ROADMAP.md | 10 +++++ CLAUDE.md | 9 ++++ src-tauri/src/chat/bridge.rs | 84 ++++++++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/.tickets/_docs/ROADMAP.md b/.tickets/_docs/ROADMAP.md index 30ff1eed..085c4d84 100644 --- a/.tickets/_docs/ROADMAP.md +++ b/.tickets/_docs/ROADMAP.md @@ -35,6 +35,16 @@ replaces the terminal. with a 60s budget and **never** hard-kills — exhausting it injects anyway and leaves the pane inspectable. `CLI_HEALTH_SPECS` now probes the interactive codex path so the same drift can't ship silently again. +- Interactive trigger mode: the prompt now actually gets submitted (2026-08-22). + It was injected as a multiline `send-keys -l` payload, which leaves the TUI in + multi-line input so the following Enter adds a line rather than sending; and + the Enter was fired with no settle, so even a flattened payload had it dropped + mid-ingest. Since the default prompt is multiline, interactive triggers had + never submitted their own prompt — the pane sat with the text visible and + unsent until the 2-hour timeout. Verified end to end: agent read `.agent.md`, + fixed the bug, wrote its proof file, and emitted the done sentinel, which was + detected and persisted as the advisory (interactive is deliberately advisory, + not auto-advancing — the session stays alive for the human). - Managed (bubbles) trigger mode made actually usable (2026-08-21), all found by running claude through a real column: it never passed `--dangerously-skip-permissions`, so every file edit was denied and the agent diff --git a/CLAUDE.md b/CLAUDE.md index 9cf5db63..836dfcb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,15 @@ The legacy DB tokens `'terminal'` and `'managed'` are both headless variants (te **Interactive mode** (gated by `KAITENCODE_INTERACTIVE_MODE_ENABLED=1` until the dev flag is promoted): - Spawns the real CLI TUI (no `-p`/`exec`) inside the tmux session via `chat::bridge::spawn_interactive_cli` — argv-based dispatch on `InteractiveCli::Claude` vs `InteractiveCli::Codex` - Waits for the pane to be usable before injecting the initial prompt: a CLI-specific ready glyph as a fast path (Claude: `╭`/`╰` box-drawing chars; Codex: `codex` banner substring), falling back to **pane quiescence** (content unchanged for ~750ms). The budget is 60s and exhausting it is **not** fatal — `ReadinessTracker`/`ReadinessVerdict` return `GiveUp`, and the caller injects anyway and leaves the session alive so the pane stays inspectable. (It used to be a fixed 5s budget that killed the session on miss, which destroyed the evidence for every slow cold start.) +- **The injected prompt is flattened to one line** (`bridge::flatten_for_injection`) + and Enter is sent after a short settle (`INTERACTIVE_SUBMIT_SETTLE`). Both are + load-bearing: a multiline `send-keys -l` payload leaves the TUI in multi-line + input so the following Enter adds a line instead of submitting, and an Enter + sent immediately after the text is dropped while the composer is still + ingesting. Either way the prompt sits visible-but-unsent and the trigger waits + out its 2-hour timeout. Since the default prompt is + `"<title>\n\nSee .task.md for full spec."`, interactive triggers had never + actually submitted their own prompt. - Carries the done-sentinel when the column's exit criteria is `agent_complete` or `manual_approval` — but the mechanism differs per CLI. **Claude:** `--append-system-prompt`. **Codex has no such flag** (verified against codex-cli 0.145.0), so its sentinel is folded into the injected prompt by `append_sentinel_to_prompt`, which must stay newline-free because the prompt goes in as one `send-keys -l` payload. - Resume is modelled by `InteractiveResume` (`None` / `Id` / `Last`). Codex's `resume` is a **subcommand**, not a flag, and is cwd-filtered — so `resume --last` in a task worktree continues that task's session, and `agent_restart` uses it. Claude needs an explicit session id we don't capture from the TUI, so claude restarts fresh. - A 2s-cadence watcher (`watch_interactive_sentinel`) scans `tmux capture-pane` for `<<<KAITENCODE_DONE:{task_id}>>>` (after ANSI strip, line-anchored) and runs `mark_complete` on hit diff --git a/src-tauri/src/chat/bridge.rs b/src-tauri/src/chat/bridge.rs index f45ecc3f..b82d2977 100644 --- a/src-tauri/src/chat/bridge.rs +++ b/src-tauri/src/chat/bridge.rs @@ -2610,14 +2610,49 @@ pub(crate) fn interactive_sentinel_system_prompt(task_id: &str) -> String { ) } +/// Collapse a prompt to a single line before injecting it into a TUI. +/// +/// The initial prompt goes in as one `tmux send-keys -l` payload. Embedded +/// newlines do **not** submit each line as you might expect — they put the TUI +/// into multi-line input, and the single `Enter` that follows adds another +/// line instead of sending. The result is the whole prompt sitting in the +/// composer, unsubmitted, while the trigger waits for a completion that can +/// never arrive. +/// +/// This was not hypothetical: the default trigger prompt is +/// `"<title>\n\nSee .task.md for full spec."`, so interactive trigger mode had +/// never actually submitted its own prompt. Observed directly in the pane. +/// +/// Nothing is lost by flattening — the detail lives in `.task.md` and +/// `.agent.md`, and the prompt is only a pointer to them. +/// Pause between injecting the prompt text and pressing Enter. +/// +/// Generous on purpose: the cost of waiting is a fraction of a second on a run +/// that lasts minutes, while the cost of being too quick is a trigger that +/// hangs until its 2-hour timeout with the prompt sitting unsent in the +/// composer. +const INTERACTIVE_SUBMIT_SETTLE: Duration = Duration::from_millis(600); + +pub(crate) fn flatten_for_injection(prompt: &str) -> String { + prompt + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::<Vec<_>>() + .join(" ") +} + /// Fold the sentinel instruction into the initial prompt, for CLIs that have /// no system-prompt flag to carry it (codex — verified against 0.145.0, which /// has no `--append-system-prompt`). /// /// MUST stay newline-free. The initial prompt is delivered as a single -/// `tmux send-keys -l` payload, and a literal newline inside it submits the -/// line early in a TUI — so the instruction is appended on the same line -/// rather than as the `"<task>\n\nWhen done…"` block shape you might expect. +/// `tmux send-keys -l` payload, and a literal newline inside it leaves the TUI +/// in multi-line input — the following `Enter` then adds a line instead of +/// submitting, and the prompt sits in the composer forever. So the instruction +/// is appended on the same line rather than as the `"<task>\n\nWhen done…"` +/// block shape you might expect. See [`flatten_for_injection`], which enforces +/// the same rule for the prompt this is appended to. pub(crate) fn append_sentinel_to_prompt(prompt: &str, task_id: &str) -> String { let instruction = interactive_sentinel_system_prompt(task_id); let base = prompt.trim_end(); @@ -2894,7 +2929,7 @@ pub(crate) fn spawn_interactive_cli( let cli_exec = resolve_cli_exec(cli_command); // Claude carries the sentinel as a system prompt (isolated from the user's // text). Codex has no such flag, so its sentinel rides the prompt instead. - let mut prompt_to_inject = initial_prompt.to_string(); + let mut prompt_to_inject = flatten_for_injection(initial_prompt); let argv = match cli { InteractiveCli::Claude => { let resume_id = match resume { @@ -3006,6 +3041,15 @@ pub(crate) fn spawn_interactive_cli( if !prompt_to_inject.is_empty() { run_tmux(&["send-keys", "-t", &session, "-l", &prompt_to_inject]) .map_err(|e| format!("send-keys (literal) for interactive prompt failed: {}", e))?; + + // Let the composer ingest the payload before submitting. These TUIs + // process pasted text asynchronously, and an `Enter` that lands while + // the buffer is still being filled is simply dropped: the prompt then + // sits in the composer, visible and unsent, while the trigger waits + // for a completion that can never come. Observed directly — the same + // pane submitted instantly when Enter was sent by hand a moment later. + std::thread::sleep(INTERACTIVE_SUBMIT_SETTLE); + run_tmux(&["send-keys", "-t", &session, "Enter"]) .map_err(|e| format!("send-keys Enter for interactive prompt failed: {}", e))?; } @@ -4183,6 +4227,38 @@ mod tests { assert_eq!(argv, vec!["codex"]); } + #[test] + fn flatten_for_injection_collapses_the_default_trigger_prompt() { + // The default prompt is multiline, and a multiline `send-keys -l` + // payload leaves the TUI in multi-line input: the following Enter adds + // a line instead of submitting, so the prompt never gets sent. This is + // the exact shape that had been silently stalling interactive triggers. + let out = flatten_for_injection("My Task\n\nSee .task.md for full spec."); + assert_eq!(out, "My Task See .task.md for full spec."); + assert!(!out.contains('\n')); + } + + #[test] + fn flatten_for_injection_handles_agent_and_edge_shapes() { + // With an agent attached the prompt gains a third line. + let withagent = flatten_for_injection( + "My Task\n\nSee .task.md for full spec.\n\nFollow the instructions in .agent.md.", + ); + assert_eq!( + withagent, + "My Task See .task.md for full spec. Follow the instructions in .agent.md." + ); + + // Already single-line prompts pass through untouched. + assert_eq!(flatten_for_injection("just one line"), "just one line"); + // Blank and whitespace-only inputs collapse to empty, not to spaces — + // the caller skips injection entirely when the payload is empty. + assert_eq!(flatten_for_injection(""), ""); + assert_eq!(flatten_for_injection("\n\n \n"), ""); + // Indentation from a templated prompt doesn't leak in as double spaces. + assert_eq!(flatten_for_injection("a\n b\n\n c "), "a b c"); + } + #[test] fn test_append_sentinel_to_prompt_embeds_marker_without_newlines() { let out = append_sentinel_to_prompt("Do the thing.", "task-zzz");