Skip to main content

agentmux_srv/server/agent_handlers/
side_question.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backend half of the `/btw` slash command — a faithful recreation of
5//! Claude Code CLI's own native `/btw`: a fast, one-shot, tool-less answer
6//! that is aware of the current conversation (via a text prefix, not
7//! `--resume`), WITHOUT touching the asking pane's persisted transcript and
8//! WITHOUT blocking/queuing behind that pane's own live turn.
9//!
10//! ## Why a throwaway block
11//!
12//! `SubprocessController::spawn_turn`
13//! (`blockcontroller/subprocess/host_spawn.rs`) is strictly one-turn-at-a-
14//! time PER BLOCK — a second `spawn_turn` on a block that's already running
15//! queues behind it (`inner.pending_messages`), plus there's a cross-
16//! process session-ownership lease on top. Calling it again on the SAME
17//! block id that owns the pane's live turn would therefore queue behind
18//! that turn and defeat the entire point of `/btw`.
19//!
20//! So this spawns the one-shot turn against a FRESH, throwaway block id —
21//! a different block id means a different `SubprocessController` instance
22//! (`blockcontroller`'s registry is keyed per block), so there is no
23//! queue/lease contention with the asking pane's real live turn, while
24//! still going through the exact same `run_agent_turn`/`AgentTurnDeps`
25//! pipeline every real agent turn uses — identity injection, the OAuth
26//! spawn gate that "fails closed", muxbus/bashwrap env, PATH, git identity
27//! — instead of hand-rolling a second, parallel spawn path that bypasses
28//! all of it. See `input.rs`'s `run_agent_turn` doc comment for why that
29//! machinery must not be reimplemented.
30//!
31//! The throwaway block deliberately carries no `agentId` / `agentName` /
32//! `agentMode` / `agent:*` meta:
33//!   - No `agentId` ⇒ `shell::resolve_global_output_zone` (file_ops.rs)
34//!     resolves to `None` for this block, so `spawn_turn`'s own stdout
35//!     mirroring never writes into any real agent's persisted
36//!     `agent:<defId>:current` transcript zone.
37//!   - It is never linked into any tab/layout tree and is deleted (with
38//!     its controller and MPS-persisted history) the moment the turn ends
39//!     — see `cleanup_throwaway_block`.
40//!   - Known scope limitation: with no `db_agent_instances` row bound to
41//!     this block, `inject_identity_env_async`
42//!     (`identity/resolver/inject.rs`) takes its documented "block has no
43//!     agent instance row — nothing to inject" early return, the same
44//!     behavior an ad hoc quick-launch pane already gets. A `/btw` asked
45//!     from a pane whose agent is bound to a specific non-ambient identity
46//!     does not inherit that binding; it runs on ambient credentials.
47//!     Reproducing the source pane's exact bound-identity resolution would
48//!     mean minting (and tearing down) a real `db_agent_instances` row for
49//!     a block that is never a real agent — left as a follow-up.
50//!   - Similarly, no `agentMode` meta ⇒ always the plain host subprocess
51//!     branch, never the container `docker exec` branch, regardless of
52//!     what the source pane is. A `/btw` from a container agent's pane
53//!     therefore answers from the HOST's view of the provider CLI.
54//!
55//! ## Providers
56//!
57//! `/btw` is an AgentMux feature, not a pass-through to a CLI's own, so it
58//! runs on Claude, Codex and Gemini panes alike ([`BtwProvider`], from the
59//! source pane's `agentProvider`). Each gets its own tool-less one-shot
60//! argv — `build_side_question_argv`, `build_codex_side_question_argv`,
61//! `build_gemini_side_question_argv` — and its own answer translator. The
62//! turn being tool-less on every provider is what keeps a side question
63//! outside the agent identity system: it can't call the agentmux tools,
64//! so it never acts as the pane's agent (#3579). Other providers are
65//! refused with an error chunk.
66//!
67//! ## Output contract
68//!
69//! The turn's own stdout is written to the THROWAWAY block's own
70//! FileStore-backed `output` file by `spawn_turn` exactly like any other
71//! turn (needed so this module can read it back) — but that file's block
72//! scope (`block:<throwaway_id>`) is never surfaced to any pane; nothing
73//! is ever subscribed to it, and the block + its persisted history are
74//! deleted as soon as the turn ends.
75//!
76//! Instead, this module polls that file for newly-appended JSON lines
77//! (there is no in-process "call me back on new bytes" primitive in this
78//! codebase — `reactive::progress_watcher` polls the same way for the same
79//! reason), translates each line through the provider's translator
80//! (`agents/translator/` — for Claude, the SAME `ClaudeTranslator` the
81//! drone Agent block runner uses), and republishes one `mps::EVENT_BTW_ANSWER_CHUNK` per translated
82//! `AgentEvent` — scoped to the SOURCE pane's block id (`block_id` in the
83//! request) plus the minted `request_id`, never the throwaway block's own
84//! scope. A final chunk carries `"done": true`.
85
86use std::sync::Arc;
87use std::time::Duration;
88
89use serde_json::Value;
90
91use crate::agents::translator::claude::ClaudeTranslator;
92use crate::agents::translator::codex::CodexTranslator;
93use crate::agents::translator::gemini::GeminiTranslator;
94use crate::agents::translator::Translator;
95use crate::agents::types::AgentEvent;
96use crate::backend::blockcontroller;
97use crate::backend::blockcontroller::subprocess::argv::{
98    build_codex_side_question_argv, build_gemini_side_question_argv, build_side_question_argv,
99    model_flag_value, GEMINI_DENY_ALL_TOOLS_POLICY,
100};
101use crate::backend::mps;
102use crate::backend::obj::{Block, MetaMapType};
103use crate::backend::rpc::engine::WshRpcEngine;
104use crate::backend::rpc_types::{
105    AskSideQuestionResult, CommandAskSideQuestionData, COMMAND_ASK_SIDE_QUESTION,
106};
107use crate::backend::storage::filestore::FileStore;
108use crate::backend::storage::store::Store;
109
110use super::super::AppState;
111use super::input::{run_agent_turn, AgentTurnDeps, TurnRegistration};
112
113/// Meta keys deliberately copied from the source block onto the throwaway
114/// block — its whole one-shot CLI configuration, nothing agent-identity-
115/// shaped. See this module's doc comment for what's excluded and why.
116const COPIED_META_KEYS: &[&str] = &["cmd", "cmd:cwd", "cmd:env"];
117
118/// Max time to wait for a translated terminal event (`AgentEvent::Done` /
119/// `Error`) before giving up and synthesizing one. A `--max-turns 1`
120/// tool-less turn should finish in a few seconds; this is a generous
121/// backstop against a wedged/never-exiting subprocess, not the expected
122/// path.
123const MAX_WAIT: Duration = Duration::from_secs(120);
124/// How often to re-check the throwaway block's output file for new bytes.
125/// Tight relative to `reactive::progress_watcher`'s 3s sweep — that loop
126/// amortizes a fixed per-tick cost across every live agent block; this one
127/// drives a single, short-lived, tool-less turn where a snappy overlay
128/// matters more than sweep cost.
129const POLL_INTERVAL: Duration = Duration::from_millis(120);
130/// Cap on bytes read per poll — defensive only; a tool-less, single-turn
131/// response is never remotely this large.
132const MAX_READ_BYTES: i64 = 1024 * 1024;
133
134pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
135    let deps = AgentTurnDeps::from_state(state);
136    let mstore = state.mstore.clone();
137    let filestore = state.filestore.clone();
138    let broker = state.broker.clone();
139    let boot_id = state.boot_id.clone();
140
141    engine.register_typed(
142        COMMAND_ASK_SIDE_QUESTION,
143        move |cmd: CommandAskSideQuestionData, _ctx| {
144            let deps = deps.clone();
145            let mstore = mstore.clone();
146            let filestore = filestore.clone();
147            let broker = broker.clone();
148            let boot_id = boot_id.clone();
149            async move {
150                let request_id = uuid::Uuid::new_v4().to_string();
151                tracing::info!(
152                    source_block_id = %cmd.block_id,
153                    request_id = %request_id,
154                    "AskSideQuestion (/btw)"
155                );
156
157                // The RPC itself must not block on (or queue behind) any
158                // turn — it mints request_id and returns immediately so
159                // the frontend can start listening for
160                // EVENT_BTW_ANSWER_CHUNK before any chunk could possibly
161                // have been published yet. The real turn runs entirely in
162                // this detached task.
163                tokio::spawn(run_side_question(
164                    deps,
165                    mstore,
166                    filestore,
167                    broker,
168                    boot_id,
169                    cmd.block_id,
170                    cmd.question,
171                    cmd.context_snapshot,
172                    request_id.clone(),
173                ));
174
175                Ok(AskSideQuestionResult { request_id })
176            }
177        },
178    );
179}
180
181/// The MPS scope a `/btw` answer streams on — the SOURCE pane's block id,
182/// never the throwaway block that actually ran the turn.
183fn btw_scope(source_block_id: &str, request_id: &str) -> String {
184    format!("block:{source_block_id}:btw:{request_id}")
185}
186
187/// The prompt sent to the CLI: `context_snapshot` prepended to `question`.
188/// No `--resume` on this path — live session context is deliberately
189/// carried as a text prefix instead (concurrent `--resume` on a live
190/// session is unsafe per this module's own doc comment).
191fn build_prompt(context_snapshot: &str, question: &str) -> String {
192    if context_snapshot.trim().is_empty() {
193        question.to_string()
194    } else {
195        format!("{context_snapshot}\n\n{question}")
196    }
197}
198
199/// The CLI a `/btw` runs on — the source pane's. See the module doc's
200/// "Providers" section.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202enum BtwProvider {
203    Claude,
204    Codex,
205    Gemini,
206}
207
208impl BtwProvider {
209    /// From the source pane's `agentProvider`. A pane without one is a
210    /// Claude pane — the only kind `/btw` served before the others.
211    fn of(source_meta: &MetaMapType) -> Result<Self, String> {
212        match crate::backend::obj::meta_get_string(source_meta, "agentProvider", "").as_str() {
213            "" | "claude" => Ok(Self::Claude),
214            "codex" => Ok(Self::Codex),
215            "gemini" => Ok(Self::Gemini),
216            other => Err(format!("/btw isn't available for {other} agents")),
217        }
218    }
219
220    fn cli_command(self) -> &'static str {
221        match self {
222            Self::Claude => "claude",
223            Self::Codex => "codex",
224            Self::Gemini => "gemini",
225        }
226    }
227
228    fn translator(self) -> Box<dyn Translator> {
229        match self {
230            Self::Claude => Box::new(ClaudeTranslator::new()),
231            Self::Codex => Box::new(CodexTranslator::new()),
232            Self::Gemini => Box::new(GeminiTranslator::new()),
233        }
234    }
235}
236
237/// Write [`GEMINI_DENY_ALL_TOOLS_POLICY`] where `--policy` can read it, and
238/// return its path. Under the AgentMux data dir rather than a shared temp
239/// dir, where another local user could swap in a permissive policy.
240/// Written to a temp file and renamed, so a concurrent `/btw` never reads
241/// a half-written policy.
242fn ensure_gemini_policy() -> Result<String, String> {
243    ensure_gemini_policy_in(&crate::backend::base::get_mux_data_dir().join("btw"))
244}
245
246fn ensure_gemini_policy_in(dir: &std::path::Path) -> Result<String, String> {
247    std::fs::create_dir_all(dir).map_err(|e| format!("btw: create {}: {e}", dir.display()))?;
248    let path = dir.join("gemini-deny-all-tools.toml");
249    if std::fs::read_to_string(&path).ok().as_deref() != Some(GEMINI_DENY_ALL_TOOLS_POLICY) {
250        let tmp = dir.join(format!(".gemini-deny-all-tools.{}.tmp", uuid::Uuid::new_v4()));
251        std::fs::write(&tmp, GEMINI_DENY_ALL_TOOLS_POLICY)
252            .and_then(|()| std::fs::rename(&tmp, &path))
253            .map_err(|e| {
254                let _ = std::fs::remove_file(&tmp);
255                format!("btw: write {}: {e}", path.display())
256            })?;
257    }
258    Ok(path.to_string_lossy().into_owned())
259}
260
261/// Build the throwaway block's meta from the source block's — its one-shot
262/// CLI config with `provider`'s tool-less `/btw` argv, minus every
263/// agent-identity-shaped key. `gemini_policy` is the deny-all policy path
264/// (read only for Gemini). Pure and unit-testable; see the module doc
265/// comment for the exclusion rationale.
266fn build_throwaway_meta(source_meta: &MetaMapType, provider: BtwProvider, gemini_policy: &str) -> MetaMapType {
267    let mut meta = MetaMapType::new();
268    for key in COPIED_META_KEYS {
269        if let Some(v) = source_meta.get(*key) {
270            meta.insert((*key).to_string(), v.clone());
271        }
272    }
273    if meta.get("cmd").is_none() {
274        meta.insert("cmd".to_string(), Value::String(provider.cli_command().to_string()));
275    }
276
277    let source_args: Option<Vec<String>> = match source_meta.get("cmd:args") {
278        Some(Value::Array(arr)) => Some(
279            arr.iter()
280                .filter_map(|v| v.as_str().map(|s| s.to_string()))
281                .collect(),
282        ),
283        _ => None,
284    };
285    let model = source_args.as_deref().and_then(model_flag_value);
286    let cli_args = match provider {
287        // A persistent pane's `cmd:args` carry the control protocol's flags
288        // (`--input-format stream-json`, `--permission-prompt-tool stdio`). This
289        // is a one-shot turn with the prompt written raw to stdin, so under them
290        // the CLI dies with "Error parsing streaming input line" (observed on
291        // 2.1.285). The same heal a container turn gets removes them and keeps the
292        // pane's model, effort and flags (G11).
293        BtwProvider::Claude => build_side_question_argv(&super::input::container_argv(
294            source_args.unwrap_or_else(|| {
295                vec![
296                    "-p".to_string(),
297                    "--output-format".to_string(),
298                    "stream-json".to_string(),
299                ]
300            }),
301            "claude",
302        )),
303        BtwProvider::Codex => build_codex_side_question_argv(model.as_deref()),
304        BtwProvider::Gemini => build_gemini_side_question_argv(gemini_policy, model.as_deref()),
305    };
306    meta.insert(
307        "cmd:args".to_string(),
308        Value::Array(cli_args.into_iter().map(Value::String).collect()),
309    );
310
311    meta
312}
313
314/// Create the throwaway block and drive it through `run_agent_turn`. Returns
315/// the throwaway block id on success — including when `run_agent_turn`
316/// itself fails after the block was created (the block + controller still
317/// exist and get cleaned up normally; `drain_and_publish`'s own
318/// not-running-with-nothing-published fallback surfaces that failure, and
319/// in the common failure case — the identity/OAuth spawn gate — the block's
320/// own output file already carries a translatable error `result` frame, so
321/// the real message reaches the frontend either way). Only returns `Err`
322/// when nothing was ever created (source block missing, insert failed) —
323/// there is then genuinely nothing to poll or clean up.
324async fn spawn_throwaway_turn(
325    deps: &AgentTurnDeps,
326    mstore: &Arc<Store>,
327    boot_id: &Arc<str>,
328    source_block_id: &str,
329    prompt: String,
330) -> Result<(String, BtwProvider), String> {
331    let source: Block = mstore
332        .get(source_block_id)
333        .map_err(|e| format!("btw: load source block: {e}"))?
334        .ok_or_else(|| format!("btw: source block {source_block_id} not found"))?;
335
336    let provider = BtwProvider::of(&source.meta)?;
337    let gemini_policy = match provider {
338        BtwProvider::Gemini => ensure_gemini_policy()?,
339        _ => String::new(),
340    };
341    let meta = build_throwaway_meta(&source.meta, provider, &gemini_policy);
342
343    let block_id = uuid::Uuid::new_v4().to_string();
344    let mut block = Block {
345        oid: block_id.clone(),
346        meta,
347        ..Default::default()
348    };
349    mstore
350        .insert(&mut block)
351        .map_err(|e| format!("btw: insert throwaway block: {e}"))?;
352
353    let ctrl = blockcontroller::subprocess::SubprocessController::new(
354        String::new(), // tab_id — never tab-attached
355        block_id.clone(),
356        Some(deps.broker.clone()),
357        // event_bus: no obj:update broadcasts needed for a block that
358        // never appears in any layout/tab tree.
359        None,
360        Some(mstore.clone()),
361        Some(deps.filestore_gate.clone()),
362        mstore.shared_agent_registry(),
363        boot_id.clone(),
364    );
365    let ctrl = Arc::new(ctrl);
366    ctrl.set_self_ref();
367    blockcontroller::register_controller(&block_id, ctrl);
368
369    if let Err(e) = run_agent_turn(
370        deps,
371        block_id.clone(),
372        prompt,
373        None,
374        TurnRegistration::Skip,
375        crate::backend::blockcontroller::health::TurnOrigin::System,
376        Vec::new(),
377    )
378    .await
379    {
380        tracing::warn!(
381            block_id = %block_id,
382            error = %e,
383            "btw: one-shot turn failed to start"
384        );
385        // Fall through — see this function's own doc comment.
386    }
387
388    Ok((block_id, provider))
389}
390
391/// Drive one `/btw` request end to end: spawn the throwaway turn, drain +
392/// translate + republish its output, then clean up.
393async fn run_side_question(
394    deps: AgentTurnDeps,
395    mstore: Arc<Store>,
396    filestore: Arc<FileStore>,
397    broker: Arc<mps::Broker>,
398    boot_id: Arc<str>,
399    source_block_id: String,
400    question: String,
401    context_snapshot: String,
402    request_id: String,
403) {
404    let scope = btw_scope(&source_block_id, &request_id);
405    let prompt = build_prompt(&context_snapshot, &question);
406
407    let (throwaway_block_id, provider) = match spawn_throwaway_turn(
408        &deps,
409        &mstore,
410        &boot_id,
411        &source_block_id,
412        prompt,
413    )
414    .await
415    {
416        Ok(spawned) => spawned,
417        Err(e) => {
418            publish_chunk(
419                &broker,
420                &scope,
421                &source_block_id,
422                &request_id,
423                &AgentEvent::Error { message: e },
424                true,
425            );
426            return;
427        }
428    };
429
430    drain_and_publish(
431        &broker,
432        &filestore,
433        provider.translator(),
434        &scope,
435        &source_block_id,
436        &request_id,
437        &throwaway_block_id,
438    )
439    .await;
440
441    cleanup_throwaway_block(&mstore, &broker, &throwaway_block_id);
442}
443
444/// Kill the throwaway controller, delete its block row, and purge any MPS-
445/// persisted history under its scope — mirrors `COMMAND_DELETE_SUB_BLOCK`'s
446/// kill-then-delete ordering (`server/websocket.rs`). Best-effort: this
447/// runs after the turn is already over, so a failure here is a leftover
448/// row/controller to log, not a failed `/btw` answer.
449fn cleanup_throwaway_block(mstore: &Arc<Store>, broker: &mps::Broker, throwaway_block_id: &str) {
450    crate::sagas::agent_teardown::discard(throwaway_block_id);
451    if let Err(e) = mstore.delete::<Block>(throwaway_block_id) {
452        tracing::warn!(
453            block_id = %throwaway_block_id,
454            error = %e,
455            "btw: failed to delete throwaway block row"
456        );
457    }
458    broker.purge_scope(&format!("block:{throwaway_block_id}"));
459}
460
461/// Poll the throwaway block's own `output` FileStore subject for newly
462/// appended stream-json lines, translate each into `AgentEvent`s, and
463/// publish one `EVENT_BTW_ANSWER_CHUNK` per event on `scope`. Returns once
464/// a terminal event (`Done`/`Error`) has been published, or after
465/// synthesizing one on timeout / unexpected exit. See the module doc
466/// comment for why this polls FileStore directly instead of hooking into
467/// `spawn_turn`'s own output path.
468async fn drain_and_publish(
469    broker: &mps::Broker,
470    filestore: &FileStore,
471    mut translator: Box<dyn Translator>,
472    scope: &str,
473    source_block_id: &str,
474    request_id: &str,
475    throwaway_block_id: &str,
476) {
477    let mut offset: i64 = 0;
478    let deadline = tokio::time::Instant::now() + MAX_WAIT;
479
480    loop {
481        let mut saw_terminal = false;
482
483        if let Ok(Some(info)) = filestore.stat(throwaway_block_id, "output") {
484            let size = info.size;
485            if size > offset {
486                let want = (size - offset).min(MAX_READ_BYTES);
487                if let Ok((_, bytes)) = filestore.read_at(throwaway_block_id, "output", offset, want) {
488                    if !bytes.is_empty() {
489                        // Only consume up to the last complete line — a
490                        // read boundary can land mid-line.
491                        let consumed = match bytes.iter().rposition(|b| *b == b'\n') {
492                            Some(idx) => idx + 1,
493                            None => bytes.len(),
494                        };
495                        offset += consumed as i64;
496                        let text = String::from_utf8_lossy(&bytes[..consumed]);
497                        for line in text.lines() {
498                            let line = line.trim();
499                            if line.is_empty() {
500                                continue;
501                            }
502                            let Ok(frame) = serde_json::from_str::<Value>(line) else {
503                                continue;
504                            };
505                            for event in translator.translate(frame) {
506                                let is_terminal =
507                                    matches!(event, AgentEvent::Done { .. } | AgentEvent::Error { .. });
508                                publish_chunk(broker, scope, source_block_id, request_id, &event, is_terminal);
509                                if is_terminal {
510                                    saw_terminal = true;
511                                }
512                            }
513                        }
514                    }
515                }
516            }
517        }
518
519        if saw_terminal {
520            return;
521        }
522
523        let still_running = blockcontroller::get_block_controller_status(throwaway_block_id)
524            .map(|s| s.shellprocstatus == blockcontroller::STATUS_RUNNING)
525            .unwrap_or(false);
526        if !still_running {
527            // The subprocess ended (or was never started) with no
528            // translatable terminal frame ever appearing — synthesize one
529            // so the frontend's overlay doesn't spin forever.
530            publish_chunk(
531                broker,
532                scope,
533                source_block_id,
534                request_id,
535                &AgentEvent::Error {
536                    message: "agent exited without a final response".to_string(),
537                },
538                true,
539            );
540            return;
541        }
542
543        if tokio::time::Instant::now() >= deadline {
544            publish_chunk(
545                broker,
546                scope,
547                source_block_id,
548                request_id,
549                &AgentEvent::Error {
550                    message: "timed out waiting for a response".to_string(),
551                },
552                true,
553            );
554            return;
555        }
556
557        tokio::time::sleep(POLL_INTERVAL).await;
558    }
559}
560
561/// Publish one `EVENT_BTW_ANSWER_CHUNK`. See `mps::EVENT_BTW_ANSWER_CHUNK`'s
562/// own doc comment for the payload shape and scope-string contract.
563fn publish_chunk(
564    broker: &mps::Broker,
565    scope: &str,
566    source_block_id: &str,
567    request_id: &str,
568    event: &AgentEvent,
569    done: bool,
570) {
571    let chunk = crate::backend::mps_events::BtwAnswerChunkEvent {
572        block_id: source_block_id.to_string(),
573        request_id: request_id.to_string(),
574        event: event.clone(),
575        done,
576    };
577    broker.publish_event::<crate::backend::mps_events::BtwAnswerChunk>(&chunk, vec![scope.to_string()], 0);
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    fn strings(values: &[&str]) -> Vec<String> {
585        values.iter().map(|v| (*v).to_string()).collect()
586    }
587
588    // ── Pure helpers ─────────────────────────────────────────────────
589
590    #[test]
591    fn btw_scope_matches_the_documented_format() {
592        assert_eq!(btw_scope("blk-1", "req-2"), "block:blk-1:btw:req-2");
593    }
594
595    #[test]
596    fn build_prompt_concatenates_snapshot_and_question() {
597        assert_eq!(
598            build_prompt("earlier conversation", "what did I just ask?"),
599            "earlier conversation\n\nwhat did I just ask?",
600        );
601    }
602
603    #[test]
604    fn build_prompt_with_no_snapshot_is_just_the_question() {
605        assert_eq!(build_prompt("", "hello?"), "hello?");
606        assert_eq!(build_prompt("   ", "hello?"), "hello?");
607    }
608
609    #[test]
610    fn throwaway_meta_excludes_every_agent_identity_key() {
611        let mut source = MetaMapType::new();
612        source.insert("cmd".to_string(), Value::String("claude".to_string()));
613        source.insert(
614            "cmd:args".to_string(),
615            Value::Array(vec![Value::String("-p".to_string())]),
616        );
617        source.insert("cmd:cwd".to_string(), Value::String("/tmp/work".to_string()));
618        source.insert("agentId".to_string(), Value::String("inst-1".to_string()));
619        source.insert("agentName".to_string(), Value::String("smike".to_string()));
620        source.insert("agentMode".to_string(), Value::String("container".to_string()));
621        source.insert("agent:sessionid".to_string(), Value::String("sid-1".to_string()));
622
623        let got = build_throwaway_meta(&source, BtwProvider::Claude, "");
624
625        assert!(!got.contains_key("agentId"));
626        assert!(!got.contains_key("agentName"));
627        assert!(!got.contains_key("agentMode"));
628        assert!(!got.contains_key("agent:sessionid"));
629        assert_eq!(got.get("cmd:cwd"), Some(&Value::String("/tmp/work".to_string())));
630    }
631
632    #[test]
633    fn throwaway_meta_carries_disallowedtools_and_max_turns() {
634        let mut source = MetaMapType::new();
635        source.insert(
636            "cmd:args".to_string(),
637            Value::Array(
638                strings(&["-p", "--output-format", "stream-json"])
639                    .into_iter()
640                    .map(Value::String)
641                    .collect(),
642            ),
643        );
644        let got = build_throwaway_meta(&source, BtwProvider::Claude, "");
645        let args: Vec<String> = match got.get("cmd:args") {
646            Some(Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
647            other => panic!("expected cmd:args array, got {other:?}"),
648        };
649        assert!(args.iter().any(|a| a == "--disallowedTools"));
650        assert!(args.iter().any(|a| a == "--max-turns"));
651        assert!(!args.iter().any(|a| a == "--resume"));
652    }
653
654    #[test]
655    fn a_persistent_panes_control_protocol_flags_do_not_leak_into_the_one_shot_turn() {
656        // What a persistent Claude pane stores in cmd:args.
657        let mut source = MetaMapType::new();
658        source.insert(
659            "cmd:args".to_string(),
660            Value::Array(
661                strings(&[
662                    "-p", "--output-format", "stream-json", "--verbose",
663                    "--input-format", "stream-json",
664                    "--permission-prompt-tool", "stdio",
665                    "--permission-mode", "default",
666                    "--model", "opus", "--effort", "high",
667                    "--add-dir", "/x",
668                ])
669                .into_iter()
670                .map(Value::String)
671                .collect(),
672            ),
673        );
674        let args = args_of(&build_throwaway_meta(&source, BtwProvider::Claude, ""));
675        assert!(!args.iter().any(|a| a == "--input-format"), "{args:?}");
676        assert!(!args.iter().any(|a| a == "--permission-prompt-tool"), "{args:?}");
677        assert!(args.iter().any(|a| a == "-p"), "{args:?}");
678        // the pane's own model, effort and flags stay: /btw answers as the pane would
679        let pos = |f: &str| args.iter().position(|a| a == f).unwrap();
680        assert_eq!(args[pos("--model") + 1], "opus");
681        assert_eq!(args[pos("--effort") + 1], "high");
682        assert_eq!(args[pos("--add-dir") + 1], "/x");
683        assert!(args.iter().any(|a| a == "--disallowedTools"));
684    }
685
686    #[test]
687    fn throwaway_meta_defaults_cmd_and_args_when_source_has_none() {
688        let source = MetaMapType::new();
689        let got = build_throwaway_meta(&source, BtwProvider::Claude, "");
690        assert_eq!(got.get("cmd"), Some(&Value::String("claude".to_string())));
691        assert!(got.contains_key("cmd:args"));
692    }
693
694    fn args_of(meta: &MetaMapType) -> Vec<String> {
695        match meta.get("cmd:args") {
696            Some(Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
697            other => panic!("expected cmd:args array, got {other:?}"),
698        }
699    }
700
701    fn pane(provider: &str, cmd: &str, args: &[&str]) -> MetaMapType {
702        let mut source = MetaMapType::new();
703        source.insert("agentProvider".to_string(), Value::String(provider.to_string()));
704        source.insert("cmd".to_string(), Value::String(cmd.to_string()));
705        source.insert(
706            "cmd:args".to_string(),
707            Value::Array(strings(args).into_iter().map(Value::String).collect()),
708        );
709        source.insert("agentId".to_string(), Value::String("inst-1".to_string()));
710        source
711    }
712
713    #[test]
714    fn the_provider_comes_from_the_source_pane() {
715        assert_eq!(BtwProvider::of(&MetaMapType::new()), Ok(BtwProvider::Claude));
716        assert_eq!(BtwProvider::of(&pane("claude", "claude", &[])), Ok(BtwProvider::Claude));
717        assert_eq!(BtwProvider::of(&pane("codex", "codex", &[])), Ok(BtwProvider::Codex));
718        assert_eq!(BtwProvider::of(&pane("gemini", "gemini", &[])), Ok(BtwProvider::Gemini));
719        let err = BtwProvider::of(&pane("kimi", "kimi", &[])).unwrap_err();
720        assert!(err.contains("kimi"), "{err}");
721    }
722
723    /// A Codex pane's own argv (here the app-server's) is replaced, not
724    /// extended: the side question is a tool-less `exec` on the pane's CLI
725    /// and model, without the pane's sandbox bypass.
726    #[test]
727    fn a_codex_pane_gets_a_tool_less_exec_on_its_own_model() {
728        let source = pane("codex", "/opt/codex", &["app-server", "--listen", "stdio://", "-m", "gpt-5"]);
729        let got = build_throwaway_meta(&source, BtwProvider::Codex, "");
730        assert_eq!(got.get("cmd"), Some(&Value::String("/opt/codex".to_string())));
731        assert!(!got.contains_key("agentId"));
732        let args = args_of(&got);
733        assert_eq!(&args[..2], &strings(&["exec", "--json"])[..]);
734        assert!(args.iter().any(|a| a == "--ignore-user-config"));
735        assert!(args.windows(2).any(|w| w == strings(&["-m", "gpt-5"])));
736        assert!(!args.iter().any(|a| a == "app-server" || a.contains("dangerously")));
737    }
738
739    #[test]
740    fn a_gemini_pane_gets_the_deny_all_policy_instead_of_yolo() {
741        let source = pane("gemini", "gemini", &["--output-format", "stream-json", "--yolo", "-p", "", "-m", "gemini-3-pro"]);
742        let got = build_throwaway_meta(&source, BtwProvider::Gemini, "/data/btw/policy.toml");
743        let args = args_of(&got);
744        assert!(args.windows(2).any(|w| w == strings(&["--policy", "/data/btw/policy.toml"])));
745        assert!(args.windows(2).any(|w| w == strings(&["-m", "gemini-3-pro"])));
746        assert!(!args.iter().any(|a| a == "--yolo"));
747    }
748
749    #[test]
750    fn the_gemini_policy_file_holds_the_deny_all_policy() {
751        let tmp = tempfile::tempdir().unwrap();
752        let dir = tmp.path().join("btw");
753        let path = ensure_gemini_policy_in(&dir).unwrap();
754        assert_eq!(std::fs::read_to_string(&path).unwrap(), GEMINI_DENY_ALL_TOOLS_POLICY);
755        // A tampered policy is put back; no temp files are left behind.
756        std::fs::write(&path, "[[rule]]\ntoolName = \"*\"\ndecision = \"allow\"\n").unwrap();
757        assert_eq!(ensure_gemini_policy_in(&dir).unwrap(), path);
758        assert_eq!(std::fs::read_to_string(&path).unwrap(), GEMINI_DENY_ALL_TOOLS_POLICY);
759        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
760    }
761
762    // ── Integration: the RPC handler itself must not block ─────────────
763
764    #[tokio::test]
765    async fn ask_side_question_returns_a_request_id_without_blocking() {
766        // Shared in-memory AppState harness (`server/tests.rs`'s
767        // `test_state()`, `pub(crate)` for exactly this kind of cross-file
768        // RPC-handler test — see also `agent_handlers/mod.rs`'s own
769        // `build_state_with_seed`, a fuller-featured sibling of the same
770        // pattern).
771        let state = crate::server::tests::test_state();
772
773        // A source block with no real CLI configured — the spawned
774        // background turn will fail quickly (no `claude` binary resolves
775        // to anything meaningful here), which is fine: this test only
776        // asserts the RPC's own synchronous contract — mint + return
777        // promptly, never block on the turn itself.
778        let mut block = Block {
779            oid: "src-block".to_string(),
780            ..Default::default()
781        };
782        state.mstore.insert(&mut block).unwrap();
783
784        let (engine, mut rx) = WshRpcEngine::new();
785        register(&engine, &state);
786
787        let req_id = format!("test-{}", uuid::Uuid::new_v4());
788        let msg = crate::backend::rpc_types::RpcMessage {
789            command: COMMAND_ASK_SIDE_QUESTION.to_string(),
790            reqid: req_id.clone(),
791            data: Some(serde_json::json!({
792                "block_id": "src-block",
793                "question": "what does this do?",
794                "context_snapshot": "",
795            })),
796            ..Default::default()
797        };
798        engine.handle_message(msg);
799
800        let resp = tokio::time::timeout(Duration::from_secs(5), rx.recv())
801            .await
802            .expect("handler timed out — RPC must not block on the turn")
803            .expect("output channel closed");
804        assert_eq!(resp.resid, req_id);
805        assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
806        let result: AskSideQuestionResult =
807            serde_json::from_value(resp.data.unwrap()).expect("response deserialize");
808        assert!(!result.request_id.is_empty());
809        assert!(uuid::Uuid::parse_str(&result.request_id).is_ok());
810    }
811}