Skip to main content

rpi_cli/
extensions_actions.rs

1//! B5a — the `RuntimeActionHost` impl over the harness, lived in `rpi-cli`
2//! (NOT `rpi-harness`) so `rpi-extensions` stays a leaf in the crate DAG. The
3//! trait is defined in `rpi-extensions` (JSON + primitives only); this is the
4//! host side that bridges the 16 [`RuntimeActionId`] actions to the harness.
5//!
6//! 10 ops delegate to `harness.lane("main")` (the `AgentLane` surface backs
7//! `prompt_message`/`prompt_text`/`get_active_tools`/`set_active_tools`/
8//! `set_model`/`get_thinking_level`/`set_thinking_level`/`compact`/
9//! `navigate_tree`). Run-ops serialize via `acquire_run`'s `active_run` guard —
10//! a concurrent plugin invocation surfaces `lane_busy` as the harness error
11//! string, the right behavior (a plugin can't re-enter an active run).
12//!
13//! 6 non-lane ops:
14//! - `append_entry` → the harness's boundary-aware message writer, or raw
15//!   extension data on the session; `set_session_name` → `set_name`.
16//! - `get_system_prompt` → `AgentHarness::get_system_prompt` (B5a accessor).
17//! - `new_session`/`fork`/`switch_session` → reuse `crate::session`'s
18//!   create/fork/open helpers + `harness.set_session`.
19//! - `reload` → handled by `ActionBridge.reload` (B5d); the host impl is the
20//!   "not configured" fallback for bridges without a callback.
21//!
22//! ## Construction ordering (the load-bearing wrinkle)
23//!
24//! Extensions load **before** the harness is created (extensions provide the
25//! tools the harness is built with), but the plugin stores the
26//! `ActionBridge`'s `user_data` pointer during `register` and that pointer
27//! must remain valid for the whole session. So the host cannot hold the
28//! `AgentHarness` directly — it holds an [`Arc<OnceLock<WeakAgentHarness>>`]
29//! that is **empty at register time** and **filled once** by
30//! [`HarnessActionHost::set_harness`] immediately after `AgentHarness::create`
31//! succeeds. Each action upgrades that weak binding for its duration; the host
32//! must not keep tools/providers alive via adapter -> keepalive -> bridge -> host.
33//! Before construction or after final harness release, actions fail explicitly.
34//! The bridge's opaque handle stays stable without retaining the harness.
35//!
36//! The impl needs the model `catalog` for `set_model(id)` (the lane wants a
37//! `Model`, not an id) and the `cwd` for session create/fork/switch. Both are
38//! held by `rpi-cli` at build time and moved into the host.
39
40use std::collections::BTreeMap;
41use std::path::PathBuf;
42use std::sync::{Arc, OnceLock, RwLock};
43
44use rpi_ai::types::ThinkingLevel;
45use rpi_extensions::RuntimeActionHost;
46use rpi_harness::agent_harness::{
47    AgentHarness, HarnessRunOutcome, NavigationOutcome, SendMessageResult, WeakAgentHarness,
48};
49use rpi_harness::session::session::Session;
50use rpi_harness::types::SendMessageOptions;
51use tokio::runtime::Handle;
52
53use crate::session::{default_session_dir, open_session_by_id_in_dir};
54
55/// Merge built-in / extension / prompt-template commands into the
56/// `ListCommands` payload.
57///
58/// It is one function because it is the single source of truth for the shape:
59///
60/// * Built-in commands live in the TUI's `CommandRegistry`, not the plugin
61///   registry. Reporting only the extension half leaves a remote client without
62///   the most common entries (`/help`, `/model`, …).
63/// * Prompt templates live in **neither**: the TUI merges them into autocomplete
64///   separately (`build_builtin_registry` explicitly says so). They have to be
65///   read from the harness resources, or a remote `/` completion list is
66///   permanently shorter than the terminal's — same host, two command tables.
67/// * Precedence is built-in > extension > prompt, matching the TUI (built-ins
68///   register first and cannot be shadowed; a template sharing a built-in name
69///   is unreachable there too).
70/// * Extension and template names may omit the leading `/`, so normalize.
71///
72/// Built-in **aliases and hidden commands** win dispatch in the TUI without
73/// appearing as suggestions. Reserving them here keeps an extension that
74/// registers the same name from being advertised as `source:"extension"` when
75/// the built-in is what actually runs.
76fn merge_command_list_reserved(
77    builtin: Vec<(String, String)>,
78    extension: Vec<(String, String)>,
79    templates: Vec<(String, String)>,
80    reserved: Vec<String>,
81) -> Vec<serde_json::Value> {
82    // Reserved names must block *later* sources without blocking the built-ins
83    // themselves: a visible built-in canonical name is both the reservation and
84    // a real entry, so seeding `seen` with it would drop every built-in from the
85    // listing. Keep the reservation set separate and consult it only for
86    // non-builtin sources; collisions among the sources still fall to `seen`.
87    let mut reserved_names = std::collections::BTreeSet::new();
88    for name in reserved {
89        let name = if !name.starts_with('/') {
90            format!("/{name}")
91        } else {
92            name
93        };
94        reserved_names.insert(name);
95    }
96    let mut seen = std::collections::BTreeSet::new();
97    let mut out: Vec<serde_json::Value> = Vec::new();
98    for (source, commands) in [
99        ("builtin", builtin),
100        ("extension", extension),
101        ("prompt", templates),
102    ] {
103        for (name, description) in commands {
104            let name = if source != "builtin" && !name.starts_with('/') {
105                format!("/{name}")
106            } else {
107                name
108            };
109            if source != "builtin" && reserved_names.contains(&name) {
110                continue;
111            }
112            if !seen.insert(name.clone()) {
113                continue;
114            }
115            out.push(serde_json::json!({
116                "name": name,
117                "description": description,
118                "source": source,
119            }));
120        }
121    }
122    out
123}
124
125/// The `RuntimeActionHost` impl over an `AgentHarness`. Built once per session
126/// in [`crate::session::build`] — constructed **empty** before extension load
127/// (the harness doesn't exist yet), then filled via [`set_harness`](Self::set_harness)
128/// once `AgentHarness::create` succeeds. Carried inside the [`ActionBridge`] as
129/// `Arc<dyn RuntimeActionHost>`.
130///
131/// `runtime` is captured at build time so the bridge can spawn dispatch from
132/// any thread; the impl's async methods run ON that runtime (they are spawned
133/// by the trampoline), so they may freely await.
134pub struct HarnessActionHost {
135    /// Weak binding filled after construction. Each operation upgrades it;
136    /// before construction or after final owner release it returns an error.
137    harness: Arc<OnceLock<WeakAgentHarness>>,
138    /// The auth-filtered catalog (the same list the TUI `/model` selector
139    /// shows). `set_model(id)` resolves an id against this.
140    catalog: RwLock<Vec<rpi_ai::Model>>,
141    /// The session cwd — `new_session`/`fork`/`switch_session` need it to
142    /// locate the session dir.
143    cwd: PathBuf,
144    session_dir: PathBuf,
145    fresh_session_only: bool,
146    /// Parsed unknown long flags retained for extension consumption.
147    cli_flags: BTreeMap<String, serde_json::Value>,
148    /// The session's plugin-tool context (`cwd` + session id). The session
149    /// runtime actions (`new_session`/`fork`/`switch_session`) swap the session
150    /// the harness serves, and must republish the id here too — a plugin whose
151    /// tool state keyed off the old id would otherwise keep serving the session
152    /// it named before the swap.
153    tool_context: rpi_extensions::ToolCallContext,
154    children: crate::child_agents::ChildAgents,
155    side_questions: crate::side_questions::SideQuestions,
156    #[allow(dead_code)]
157    runtime: Handle,
158    /// Loaded extension registry, set once the session's extensions are in.
159    ///
160    /// Needed by `ListCommands`: the plugin ABI had no way to enumerate slash
161    /// commands, so a remote UI could not offer completion. `None` before the
162    /// registry is published (and in tests that never load extensions) — the
163    /// action then answers with an empty list rather than failing, matching
164    /// the "no commands registered" case.
165    registry: std::sync::RwLock<Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>>,
166}
167
168impl HarnessActionHost {
169    /// Build an **empty** host (no harness yet). The `runtime` is the handle
170    /// the bridge captured (kept only so the impl can name it for future
171    /// direct-spawn needs; the trampoline already spawns on the bridge's
172    /// runtime). Call [`set_harness`](Self::set_harness) once the harness is
173    /// created. Returns `(host, harness_cell)` where `harness_cell` is the
174    /// shared `OnceLock` the caller fills.
175    pub fn new_empty(
176        catalog: Vec<rpi_ai::Model>,
177        cwd: PathBuf,
178        runtime: Handle,
179        cli_flags: BTreeMap<String, serde_json::Value>,
180        tool_context: rpi_extensions::ToolCallContext,
181    ) -> (Self, Arc<OnceLock<WeakAgentHarness>>) {
182        let harness = Arc::new(OnceLock::new());
183        (
184            Self {
185                harness: Arc::clone(&harness),
186                catalog: RwLock::new(catalog),
187                session_dir: default_session_dir(&cwd),
188                fresh_session_only: false,
189                cwd,
190                cli_flags,
191                tool_context,
192                children: crate::child_agents::ChildAgents::default(),
193                side_questions: crate::side_questions::SideQuestions::default(),
194                runtime,
195                registry: std::sync::RwLock::new(None),
196            },
197            harness,
198        )
199    }
200
201    pub fn set_catalog(&self, catalog: Vec<rpi_ai::Model>) {
202        *self
203            .catalog
204            .write()
205            .unwrap_or_else(|error| error.into_inner()) = catalog;
206    }
207
208    pub(crate) fn with_fresh_session_only(mut self, locked: bool) -> Self {
209        self.fresh_session_only = locked;
210        self
211    }
212    fn allow_session_routing(&self) -> Result<(), String> {
213        if self.fresh_session_only {
214            Err("experimental native print session forbids session routing actions".into())
215        } else {
216            Ok(())
217        }
218    }
219
220    pub(crate) fn with_session_dir(mut self, session_dir: PathBuf) -> Self {
221        self.session_dir = session_dir;
222        self
223    }
224
225    /// Publish the loaded extension registry so `ListCommands` can answer.
226    ///
227    /// `None` publishes "no extensions". A reload that ends with zero plugins
228    /// must publish that too: otherwise the previous snapshot stays cached and
229    /// removed extensions keep advertising their commands.
230    pub(crate) fn publish_registry_impl(
231        &self,
232        snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
233    ) {
234        if let Ok(mut slot) = self.registry.write() {
235            *slot = snapshot;
236        }
237    }
238
239    fn registry_snapshot(&self) -> Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>> {
240        self.registry.read().ok().and_then(|slot| slot.clone())
241    }
242
243    /// Fill the harness cell. Call exactly once, immediately after
244    /// `AgentHarness::create` succeeds. Returns the host (for fluent chaining)
245    /// — the caller already holds the `Arc<dyn RuntimeActionHost>` from
246    /// construction; this just populates the cell that host reads.
247    pub fn set_harness(cell: &Arc<OnceLock<WeakAgentHarness>>, harness: Arc<AgentHarness>) {
248        let _ = cell.set(harness.downgrade());
249    }
250
251    /// Pin the harness only for this action. The binding itself must not retain
252    /// tools/providers through the plugin -> keepalive -> host reference cycle.
253    fn harness(&self) -> Result<AgentHarness, String> {
254        self.harness
255            .get()
256            .ok_or_else(|| "runtime action invoked before harness was built".to_string())?
257            .upgrade()
258            .ok_or_else(|| "runtime action invoked after harness was dropped".to_string())
259    }
260
261    /// Resolve `id` (case-insensitive exact, then substring) against the
262    /// catalog. Mirrors the resolver's exact-id-first fallback.
263    fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
264        let catalog = self.catalog.read().ok()?;
265        catalog
266            .iter()
267            .find(|m| m.id.eq_ignore_ascii_case(id))
268            .cloned()
269            .or_else(|| {
270                catalog
271                    .iter()
272                    .find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
273                    .cloned()
274            })
275    }
276}
277
278/// Serialize reclaimed queue entries for the ABI boundary.
279///
280/// Each entry is `{"text": "…", "images": [{mediaType, data}]}` — text and its
281/// own attachments **together**. A flat `images` side list would lose the
282/// pairing as soon as two queued messages carry different pictures: a restore
283/// would attach image B next to text A, or attach the picture to whichever
284/// entry happened to be first.
285pub(crate) fn queued_message_payloads(
286    messages: &[rpi_agent::AgentMessage],
287) -> Vec<serde_json::Value> {
288    messages
289        .iter()
290        .map(|message| {
291            let (text, images) = match message {
292                rpi_agent::AgentMessage::User(user) => match &user.content {
293                    rpi_ai::types::UserContent::Text(text) => (text.clone(), Vec::new()),
294                    rpi_ai::types::UserContent::Blocks(blocks) => {
295                        let text = blocks
296                            .iter()
297                            .filter_map(|block| match block {
298                                rpi_ai::types::Content::Text(text) => Some(text.text.as_str()),
299                                _ => None,
300                            })
301                            .collect::<Vec<_>>()
302                            .join("\n");
303                        let collected: Vec<_> = blocks
304                            .iter()
305                            .filter_map(|block| match block {
306                                rpi_ai::types::Content::Image(image) => Some(image.clone()),
307                                _ => None,
308                            })
309                            .collect();
310                        (text, image_payloads(&collected))
311                    }
312                },
313                _ => (String::new(), Vec::new()),
314            };
315            serde_json::json!({ "text": text, "images": images })
316        })
317        .filter(|entry| {
318            entry["text"].as_str().is_some_and(|t| !t.trim().is_empty())
319                || entry["images"].as_array().is_some_and(|i| !i.is_empty())
320        })
321        .collect()
322}
323
324/// Serialize image attachments as `{mediaType, data}`.
325fn image_payloads(images: &[rpi_ai::types::ImageContent]) -> Vec<serde_json::Value> {
326    images
327        .iter()
328        .map(|image| {
329            serde_json::json!({
330                "mediaType": image.mime_type,
331                "data": image.data,
332            })
333        })
334        .collect()
335}
336
337/// Helper: pull a string field `key` from `args` (object), or return `msg`.
338fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
339    args.get(key)
340        .and_then(|v| v.as_str())
341        .map(|s| s.to_string())
342        .ok_or_else(|| format!("missing string field `{key}` in action args"))
343}
344
345/// Helper: pull an optional string field.
346fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
347    args.get(key)
348        .and_then(|v| v.as_str())
349        .map(|s| s.to_string())
350}
351
352/// Helper: pull a bool field (default `false`).
353fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
354    args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
355}
356
357/// Helper: pull a string array field.
358fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
359    args.get(key)
360        .and_then(|v| v.as_array())
361        .map(|arr| {
362            arr.iter()
363                .filter_map(|v| v.as_str().map(|s| s.to_string()))
364                .collect()
365        })
366        .ok_or_else(|| format!("missing string-array field `{key}` in action args"))
367}
368
369/// Render a `HarnessRunOutcome` as JSON for the plugin. Only the terminal
370/// status + leaf id cross (the final message text is folded to a string; full
371/// assistant content is too rich for a v1 action result).
372pub(crate) fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
373    match outcome {
374        HarnessRunOutcome::Completed {
375            leaf_id,
376            final_entry_id,
377            final_message,
378        } => {
379            serde_json::json!({
380                "status": "completed",
381                "leafId": leaf_id,
382                "finalEntryId": final_entry_id,
383                "text": assistant_text(&final_message),
384            })
385        }
386        HarnessRunOutcome::Aborted {
387            leaf_id,
388            final_entry_id,
389            final_message,
390        } => {
391            serde_json::json!({
392                "status": "aborted",
393                "leafId": leaf_id,
394                "finalEntryId": final_entry_id,
395                "text": assistant_text(&final_message),
396            })
397        }
398        HarnessRunOutcome::Failed {
399            leaf_id,
400            error,
401            final_entry_id,
402            final_message,
403        } => {
404            serde_json::json!({
405                "status": "failed",
406                "leafId": leaf_id,
407                "error": format!("{error:?}"),
408                "finalEntryId": final_entry_id,
409                "text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
410            })
411        }
412        HarnessRunOutcome::Suspended {
413            leaf_id,
414            final_entry_id,
415            ..
416        } => {
417            serde_json::json!({
418                "status": "suspended",
419                "leafId": leaf_id,
420                "finalEntryId": final_entry_id,
421            })
422        }
423    }
424}
425
426/// Accept the SDK's flat action args and Pi-style nested options. Reject mixed
427/// forms instead of silently choosing one set of scheduling instructions.
428fn send_message_options(args: &serde_json::Value) -> Result<SendMessageOptions, String> {
429    let flat = args.get("triggerTurn").is_some() || args.get("deliverAs").is_some();
430    let value = if let Some(options) = args.get("options").filter(|value| !value.is_null()) {
431        if !options.is_object() {
432            return Err("send_message options must be an object".into());
433        }
434        if flat {
435            return Err(
436                "put triggerTurn/deliverAs either in options or at the top level, not both".into(),
437            );
438        }
439        options.clone()
440    } else {
441        let mut fields = serde_json::Map::new();
442        for key in ["triggerTurn", "deliverAs"] {
443            if let Some(value) = args.get(key) {
444                fields.insert(key.into(), value.clone());
445            }
446        }
447        serde_json::Value::Object(fields)
448    };
449    serde_json::from_value(value).map_err(|error| format!("invalid send_message options: {error}"))
450}
451
452fn send_message_input(args: &serde_json::Value) -> Result<rpi_agent::AgentMessage, String> {
453    let timestamp = std::time::SystemTime::now()
454        .duration_since(std::time::UNIX_EPOCH)
455        .unwrap_or_default()
456        .as_millis()
457        .min(i64::MAX as u128) as i64;
458    if let Some(message) = args.get("message") {
459        if message.get("kind").is_some() {
460            return serde_json::from_value(message.clone())
461                .map_err(|error| format!("invalid message: {error}"));
462        }
463        // Pi's custom message input does not include the stored AgentMessage
464        // discriminator. Preserve customType/display/details via its constructor.
465        #[derive(serde::Deserialize)]
466        #[serde(rename_all = "camelCase")]
467        struct CustomInput {
468            custom_type: String,
469            #[serde(default)]
470            content: Option<rpi_ai::types::UserContent>,
471            #[serde(default)]
472            display: bool,
473            #[serde(default)]
474            details: Option<serde_json::Value>,
475        }
476        let input: CustomInput = serde_json::from_value(message.clone())
477            .map_err(|error| format!("invalid custom message: {error}"))?;
478        return Ok(rpi_harness::messages::create_custom_message(
479            &input.custom_type,
480            input
481                .content
482                .unwrap_or(rpi_ai::types::UserContent::Blocks(Vec::new())),
483            input.display,
484            input.details,
485            timestamp,
486        ));
487    }
488    let text =
489        arg_str(args, "text").map_err(|_| "missing `message` or `text` field".to_string())?;
490    Ok(rpi_agent::AgentMessage::User(
491        rpi_ai::types::UserMessage::new(text, timestamp),
492    ))
493}
494
495fn send_message_result(result: SendMessageResult) -> serde_json::Value {
496    match result {
497        SendMessageResult::Appended { entry_id } => {
498            serde_json::json!({"status":"appended", "entryId":entry_id})
499        }
500        SendMessageResult::Queued { entry_id, queue } => {
501            if queue == rpi_harness::session::types::QueueKind::Context {
502                return serde_json::json!({"status":"queued", "entryId":entry_id, "queue":"context"});
503            }
504            let delivery = match queue {
505                rpi_harness::session::types::QueueKind::NextRun => "nextTurn",
506                _ => queue.as_str(),
507            };
508            serde_json::json!({"status":"queued", "entryId":entry_id, "deliverAs":delivery})
509        }
510        SendMessageResult::Run(run) => run_outcome_json(run.outcome),
511    }
512}
513
514/// Extract the concatenated text from an assistant message (the `text` blocks).
515pub(crate) fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
516    msg.content
517        .iter()
518        .filter_map(|b| match b {
519            rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
520            _ => None,
521        })
522        .collect::<Vec<_>>()
523        .join("")
524}
525
526#[async_trait::async_trait]
527impl RuntimeActionHost for HarnessActionHost {
528    fn cancel_side_questions(&self, owner: u64) {
529        self.side_questions.cancel_owner(owner);
530    }
531    fn side_question_revision(&self) -> u64 {
532        self.side_questions.revision()
533    }
534    fn side_question_views(&self) -> Vec<serde_json::Value> {
535        self.side_questions.views()
536    }
537    async fn side_question_ui(
538        &self,
539        owner: u64,
540        thread_id: String,
541        args: serde_json::Value,
542    ) -> Result<serde_json::Value, String> {
543        self.side_questions
544            .ui_handle(&self.harness()?, owner, thread_id, args)
545            .await
546    }
547    async fn side_question(
548        &self,
549        owner: u64,
550        args: serde_json::Value,
551        sink: rpi_extensions::SideQuestionSink,
552    ) -> Result<serde_json::Value, String> {
553        self.side_questions
554            .handle(&self.harness()?, owner, args, sink)
555            .await
556    }
557
558    fn cancel_child_agents(&self, owner: u64) {
559        self.children.cancel_owner(owner);
560    }
561    fn replace_model_catalog(&self, models: Vec<rpi_ai::Model>) {
562        self.set_catalog(models);
563    }
564    async fn list_commands(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
565        let builtin = crate::interactive_tui::build_builtin_commands_for_abi();
566        let extension: Vec<(String, String)> = self
567            .registry_snapshot()
568            .as_deref()
569            .map(|snapshot| {
570                snapshot
571                    .commands()
572                    .iter()
573                    .map(|c| (c.name.clone(), c.description.clone()))
574                    .collect()
575            })
576            .unwrap_or_default();
577        // Prompt templates are harness resources, not registry entries — see
578        // `merge_command_list`. A missing/closed harness yields no templates
579        // rather than an error: "none discovered" is a real answer, and `/`
580        // completion must still work for the commands that do exist.
581        let templates: Vec<(String, String)> = match self.harness() {
582            Ok(harness) => match harness.get_resources().await {
583                Ok(resources) => resources
584                    .prompt_templates
585                    .unwrap_or_default()
586                    .into_iter()
587                    .map(|template| {
588                        (
589                            template.name,
590                            template
591                                .description
592                                .unwrap_or_else(|| "Expand prompt template".to_string()),
593                        )
594                    })
595                    .collect(),
596                Err(_) => Vec::new(),
597            },
598            Err(_) => Vec::new(),
599        };
600        Ok(serde_json::json!({
601            "commands": merge_command_list_reserved(
602                builtin,
603                extension,
604                templates,
605                crate::interactive_tui::builtin_reserved_names(),
606            )
607        }))
608    }
609
610    fn publish_registry(&self, snapshot: std::sync::Arc<rpi_extensions::RegistrySnapshot>) {
611        self.publish_registry_impl(Some(snapshot));
612    }
613
614    fn publish_registry_opt(
615        &self,
616        snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
617    ) {
618        self.publish_registry_impl(snapshot);
619    }
620
621    async fn abort(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
622        // The harness reclaims the queue and cancels the run under one write
623        // gate, so there is no `clear_queue()` + `abort()` window in which a
624        // message could be both drained into the transcript and handed back.
625        //
626        // Why this matters: the agent loop drains the steering queue on abort
627        // and emits those messages as transcript content. Native-pi's TUI
628        // avoids the resulting "ghost message" by calling `clearQueue()` first
629        // ("a cancel never silently swallows messages the user staged while the
630        // run was in flight"). A plugin has no editor to restore into, so it
631        // hands them back to its caller instead.
632        // `abort_reclaiming(true)`: one call cancels **and** reclaims under the
633        // harness's write gate, so the idle case is atomic too. A separate
634        // `clear_queue()` afterwards would let a new run start and drain the
635        // queue in between — the caller would then report "nothing was queued"
636        // for messages that were in fact consumed.
637        let harness = self.harness()?;
638        let result = harness
639            .abort_reclaiming(true)
640            .await
641            .map_err(|error| error.to_string())?;
642        Ok(serde_json::json!({
643            // Empty run id = the lane was already idle. The queue is still
644            // handed back: a stop button must be idempotent, and messages can
645            // land as a run settles.
646            "aborted": !result.run_id.is_empty(),
647            "runId": if result.run_id.is_empty() {
648                serde_json::Value::Null
649            } else {
650                serde_json::json!(result.run_id)
651            },
652            "steer": queued_message_payloads(&result.steer),
653            "followUp": queued_message_payloads(&result.follow_up),
654        }))
655    }
656
657    fn stop_round(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
658        self.harness()?.request_stop_round();
659        Ok(serde_json::json!({ "requested": true }))
660    }
661
662    async fn get_execution_context(
663        &self,
664        _args: serde_json::Value,
665    ) -> Result<serde_json::Value, String> {
666        let harness = self.harness()?;
667        let mut context = harness.execution_context().map_err(|e| e.to_string())?;
668        if let Some(serde_json::Value::Object(child)) =
669            rpi_agent::tool_execution_scope::execution_context()
670        {
671            context
672                .as_object_mut()
673                .expect("execution context is an object")
674                .extend(child);
675        }
676        context["cwd"] = serde_json::json!(self.cwd);
677        context["sessionId"] = serde_json::json!(harness.session().storage().metadata().id);
678        Ok(context)
679    }
680
681    async fn child_agent(
682        &self,
683        owner: u64,
684        args: serde_json::Value,
685    ) -> Result<serde_json::Value, String> {
686        if rpi_agent::tool_execution_scope::is_child_execution() {
687            return Err("recursive child agents are not allowed".into());
688        }
689        if args.get("op").and_then(serde_json::Value::as_str) == Some("start") {
690            self.children.handle(&self.harness()?, owner, args).await
691        } else {
692            // Reclamation must remain possible after the parent was dropped.
693            self.children.handle_existing(owner, args)
694        }
695    }
696
697    async fn get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
698        let name = arg_str(&args, "name")?;
699        Ok(serde_json::json!({
700            "value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
701        }))
702    }
703
704    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
705        let options = send_message_options(&args)?;
706        let message = send_message_input(&args)?;
707        let lane = self.harness()?.lane("main");
708        let result = lane
709            .send_message(message, options)
710            .await
711            .map_err(|e| e.to_string())?;
712        Ok(send_message_result(result))
713    }
714
715    async fn send_user_message(
716        &self,
717        args: serde_json::Value,
718    ) -> Result<serde_json::Value, String> {
719        let text = arg_str(&args, "text")?;
720        let lane = self.harness()?.lane("main");
721        // Honor the same scheduling instructions `SendMessage` accepts.
722        //
723        // The action used to call `prompt_text` directly, which ignored
724        // `deliverAs` entirely. Two consequences:
725        //
726        // 1. A message sent while a run is in flight was rejected with
727        //    "lane busy" instead of being queued — and because this action
728        //    blocks for the whole run, callers that fire-and-forget (a bridge,
729        //    a remote UI) never saw the error. The message silently vanished.
730        // 2. `steer` / `followUp` / `nextTurn` were all delivered as "start a
731        //    new prompt", so a client could not express them at all.
732        //
733        // `send_message` with `triggerTurn: Some(true)` keeps the idle behavior
734        // (start a run) while giving an active run the queueing semantics the
735        // delivery mode asks for.
736        let mut options = send_message_options(&args)?;
737        if options.trigger_turn.is_none() {
738            options.trigger_turn = Some(true);
739        }
740        let message = rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage::new(
741            text,
742            std::time::SystemTime::now()
743                .duration_since(std::time::UNIX_EPOCH)
744                .unwrap_or_default()
745                .as_millis()
746                .min(i64::MAX as u128) as i64,
747        ));
748        let result = lane
749            .send_message(message, options)
750            .await
751            .map_err(|e| e.to_string())?;
752        Ok(send_message_result(result))
753    }
754
755    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
756        // `{"message": <AgentMessage json>}` appends a message entry; OR
757        // `{"customType": "...", "data": {...}}` appends a custom entry. No run
758        // is driven. Messages use the harness boundary writer, whose entry id
759        // may be reserved until the current turn settles. Custom data stays out
760        // of model context unless a caller explicitly configures a projector.
761        if let Some(expected) = args.get("expected") {
762            let guard = serde_json::from_value(expected.clone())
763                .map_err(|error| format!("invalid AppendEntry expected guard: {error}"))?;
764            let entry = if let Some(custom_type) = arg_str_opt(&args, "customType") {
765                if args.get("message").is_some() || args.get("outboxKey").is_some() {
766                    return Err("scoped custom entry cannot include message/outboxKey".into());
767                }
768                crate::resource_selection::validate_public_entry(&custom_type, args.get("data"))?;
769                rpi_harness::agent_harness::ScopedEntry::Custom {
770                    custom_type,
771                    data: args.get("data").cloned(),
772                }
773            } else {
774                let message =
775                    serde_json::from_value(args.get("message").cloned().ok_or("missing message")?)
776                        .map_err(|error| format!("invalid message: {error}"))?;
777                let key = arg_str(&args, "outboxKey")?;
778                rpi_harness::agent_harness::ScopedEntry::Notice { message, key }
779            };
780            let id = self
781                .harness()?
782                .append_scoped_entry(&guard, entry)
783                .await
784                .map_err(|error| error.to_string())?;
785            return Ok(serde_json::json!({"entryId":id, "scoped":true}));
786        }
787        if args.get("outboxKey").is_some() {
788            return Err("outboxKey requires an expected guard".into());
789        }
790        if let Some(custom_type) = arg_str_opt(&args, "customType") {
791            crate::resource_selection::validate_public_entry(&custom_type, args.get("data"))?;
792            let data = args.get("data").cloned();
793            let id = self
794                .harness()?
795                .append_custom_entry(&custom_type, data)
796                .await
797                .map_err(|e| e.to_string())?;
798            return Ok(serde_json::json!({ "entryId": id }));
799        }
800        let msg = args
801            .get("message")
802            .ok_or_else(|| "missing `message` or `customType` field".to_string())?;
803        let message: rpi_agent::AgentMessage =
804            serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
805        let id = self
806            .harness()?
807            .append_message(message)
808            .await
809            .map_err(|e| e.to_string())?;
810        Ok(serde_json::json!({ "entryId": id }))
811    }
812
813    async fn get_session_branch(
814        &self,
815        args: serde_json::Value,
816    ) -> Result<serde_json::Value, String> {
817        let request = crate::modes::branch_entry_query(&args)?;
818        let (entries, session_id, leaf_id) =
819            crate::modes::branch_snapshot(&self.harness()?, &request).await?;
820        let entries = entries
821            .iter()
822            .map(serde_json::to_value)
823            .collect::<Result<Vec<_>, _>>()
824            .map_err(|e| format!("serialize session branch: {e}"))?;
825        Ok(serde_json::json!({
826            "entries": entries,
827            "count": entries.len(),
828            "filtered": true,
829            "sessionId": session_id,
830            "leafId": leaf_id,
831            "scopedAppend": true,
832        }))
833    }
834
835    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
836        let name = arg_str(&args, "name")?;
837        self.harness()?
838            .session()
839            .set_name(Some(&name))
840            .await
841            .map_err(|e| e.to_string())?;
842        Ok(serde_json::Value::Null)
843    }
844
845    async fn get_active_tools(
846        &self,
847        _args: serde_json::Value,
848    ) -> Result<serde_json::Value, String> {
849        let lane = self.harness()?.lane("main");
850        let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
851        Ok(serde_json::json!({ "tools": tools }))
852    }
853
854    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
855        let tools = arg_str_array(&args, "tools")?;
856        let lane = self.harness()?.lane("main");
857        lane.set_active_tools(tools)
858            .await
859            .map_err(|e| e.to_string())?;
860        Ok(serde_json::Value::Null)
861    }
862
863    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
864        let id = arg_str(&args, "model")?;
865        let model = self
866            .resolve_model(&id)
867            .ok_or_else(|| format!("model `{id}` not in catalog"))?;
868        let lane = self.harness()?.lane("main");
869        lane.set_model(model.clone())
870            .await
871            .map_err(|e| e.to_string())?;
872        Ok(serde_json::json!({ "model": model.id }))
873    }
874
875    async fn get_thinking_level(
876        &self,
877        _args: serde_json::Value,
878    ) -> Result<serde_json::Value, String> {
879        let lane = self.harness()?.lane("main");
880        let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
881        Ok(serde_json::json!({ "level": level }))
882    }
883
884    async fn set_thinking_level(
885        &self,
886        args: serde_json::Value,
887    ) -> Result<serde_json::Value, String> {
888        let level_val = args
889            .get("level")
890            .ok_or_else(|| "missing `level` field".to_string())?;
891        let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
892            serde_json::from_value(serde_json::Value::String(s.to_string()))
893                .map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
894        } else {
895            serde_json::from_value(level_val.clone())
896                .map_err(|e| format!("invalid thinking level: {e}"))?
897        };
898        let lane = self.harness()?.lane("main");
899        lane.set_thinking_level(level)
900            .await
901            .map_err(|e| e.to_string())?;
902        Ok(serde_json::Value::Null)
903    }
904
905    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
906        let custom = arg_str_opt(&args, "customInstructions");
907        let lane = self.harness()?.lane("main");
908        let result = lane
909            .compact(custom.as_deref())
910            .await
911            .map_err(|e| e.to_string())?;
912        Ok(
913            serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }),
914        )
915    }
916
917    async fn get_system_prompt(
918        &self,
919        _args: serde_json::Value,
920    ) -> Result<serde_json::Value, String> {
921        let prompt = self
922            .harness()?
923            .get_system_prompt()
924            .await
925            .map_err(|e| e.to_string())?;
926        Ok(serde_json::json!({ "prompt": prompt }))
927    }
928
929    async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
930        self.allow_session_routing()?;
931        let harness = self.harness()?;
932        let cwd_str = self.cwd.to_string_lossy().to_string();
933        let dir = &self.session_dir;
934        std::fs::create_dir_all(dir)
935            .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
936        let session = crate::session::create_jsonl_session(dir, &cwd_str)
937            .await
938            .map_err(|e| format!("create session: {e}"))?;
939        let id = session.storage().metadata().id.clone();
940        crate::session::set_active_session(&harness, session, Some(&self.tool_context)).await?;
941        Ok(serde_json::json!({ "sessionId": id }))
942    }
943
944    async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
945        self.allow_session_routing()?;
946        let harness = self.harness()?;
947        let cwd_str = self.cwd.to_string_lossy().to_string();
948        let new_session =
949            crate::session::fork_session_storage(&harness, &cwd_str, &self.session_dir)
950                .await
951                .map_err(|e| format!("fork session: {e}"))?;
952        let id = new_session.storage().metadata().id.clone();
953        crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
954        Ok(serde_json::json!({ "sessionId": id }))
955    }
956
957    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
958        self.allow_session_routing()?;
959        let target_id = arg_str_opt(&args, "targetId");
960        let summarize = arg_bool(&args, "summarize");
961        let custom = arg_str_opt(&args, "customInstructions");
962        let label = arg_str_opt(&args, "label");
963        let lane = self.harness()?.lane("main");
964        let result = lane
965            .navigate_tree(
966                target_id.as_deref(),
967                summarize,
968                custom.as_deref(),
969                label.as_deref(),
970            )
971            .await
972            .map_err(|e| e.to_string())?;
973        let status = match &result.outcome {
974            NavigationOutcome::Completed { .. } => "completed",
975            NavigationOutcome::Declined { .. } => "declined",
976            NavigationOutcome::Aborted { .. } => "aborted",
977            NavigationOutcome::Failed { .. } => "failed",
978        };
979        Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
980    }
981
982    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
983        self.allow_session_routing()?;
984        let harness = self.harness()?;
985        let id = arg_str(&args, "id")?;
986        let cwd_str = self.cwd.to_string_lossy().to_string();
987        let new_session: Session = open_session_by_id_in_dir(&id, &cwd_str, &self.session_dir)
988            .await
989            .map(|restored| restored.session)
990            .map_err(|e| e.to_string())?;
991        let new_id = new_session.storage().metadata().id.clone();
992        crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
993        Ok(serde_json::json!({ "sessionId": new_id }))
994    }
995
996    async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
997        // Reached only when `ActionBridge.reload` is `None` (no /reload wired).
998        // B5d wires the reload callback at the bridge layer; this host impl is
999        // the "not configured" fallback.
1000        Err("reload not configured (no /reload callback on this bridge)".to_string())
1001    }
1002}
1003
1004#[cfg(test)]
1005mod lifetime_tests;
1006
1007#[cfg(test)]
1008mod send_message_tests {
1009    use super::*;
1010    use rpi_harness::types::MessageDelivery;
1011
1012    #[test]
1013    fn options_accept_flat_and_nested_forms_with_pi_defaults() {
1014        assert_eq!(
1015            send_message_options(&serde_json::json!({"text":"context"})).unwrap(),
1016            SendMessageOptions::default()
1017        );
1018        let expected = SendMessageOptions {
1019            trigger_turn: Some(false),
1020            deliver_as: MessageDelivery::FollowUp,
1021        };
1022        for args in [
1023            serde_json::json!({"text":"context", "triggerTurn":false, "deliverAs":"followUp"}),
1024            serde_json::json!({"text":"context", "options":{"triggerTurn":false, "deliverAs":"followUp"}}),
1025        ] {
1026            assert_eq!(send_message_options(&args).unwrap(), expected);
1027        }
1028    }
1029
1030    #[test]
1031    fn options_reject_invalid_or_ambiguous_scheduling_instructions() {
1032        for args in [
1033            serde_json::json!({"triggerTurn":"false"}),
1034            serde_json::json!({"deliverAs":"follow-up"}),
1035            serde_json::json!({"options":{"typo":true}}),
1036            serde_json::json!({"options":[], "text":"context"}),
1037            serde_json::json!({"options":{"triggerTurn":true}, "triggerTurn":false}),
1038        ] {
1039            assert!(send_message_options(&args).is_err(), "{args}");
1040        }
1041    }
1042
1043    #[test]
1044    fn pi_custom_input_preserves_content_and_display_metadata() {
1045        let message = send_message_input(&serde_json::json!({"message":{
1046            "customType":"goal", "content":"goal state", "display":true, "details":{"step":2}
1047        }}))
1048        .unwrap();
1049        let rpi_agent::AgentMessage::Custom(custom) = &message else {
1050            panic!("custom")
1051        };
1052        assert_eq!(
1053            custom.content,
1054            vec![rpi_ai::types::Content::text("goal state")]
1055        );
1056        let data = rpi_harness::messages::custom_data(&message).unwrap();
1057        assert_eq!(data.custom_type, "goal");
1058        assert!(data.display);
1059        assert_eq!(data.details, Some(serde_json::json!({"step":2})));
1060        // The existing stored AgentMessage input remains supported.
1061        let stored = serde_json::to_value(&message).unwrap();
1062        assert_eq!(
1063            send_message_input(&serde_json::json!({"message":stored})).unwrap(),
1064            message
1065        );
1066    }
1067
1068    #[test]
1069    fn missing_custom_content_is_empty_and_queued_results_use_pi_delivery_names() {
1070        let message = send_message_input(
1071            &serde_json::json!({"message":{"customType":"goal", "content":null}}),
1072        )
1073        .unwrap();
1074        assert!(
1075            matches!(message, rpi_agent::AgentMessage::Custom(custom) if custom.content.is_empty())
1076        );
1077        assert_eq!(
1078            send_message_result(SendMessageResult::Queued {
1079                entry_id: "reserved".into(),
1080                queue: rpi_harness::session::types::QueueKind::NextRun,
1081            }),
1082            serde_json::json!({"status":"queued", "entryId":"reserved", "deliverAs":"nextTurn"})
1083        );
1084    }
1085}
1086
1087#[cfg(test)]
1088mod list_commands_tests {
1089    use super::merge_command_list_reserved;
1090
1091    /// Reserving names must not delete the built-ins from the listing.
1092    ///
1093    /// The reservation set contains every built-in canonical name, so seeding
1094    /// the dedupe set with it silently dropped all of them: the real listing
1095    /// went from 32 built-ins to 0, leaving only extension entries.
1096    #[test]
1097    fn reserved_names_do_not_remove_the_builtins_themselves() {
1098        let builtin = vec![
1099            ("/help".to_string(), "help".to_string()),
1100            ("/model".to_string(), "model".to_string()),
1101        ];
1102        let out = merge_command_list_reserved(
1103            builtin,
1104            vec![("deploy".into(), "deploy".into())],
1105            vec![],
1106            // Every built-in name is reserved (as in production), plus an alias
1107            // and a hidden command that are not listed.
1108            vec![
1109                "/help".to_string(),
1110                "/model".to_string(),
1111                "/q".to_string(),
1112                "/context".to_string(),
1113            ],
1114        );
1115        assert_eq!(names(&out), vec!["/help", "/model", "/deploy"]);
1116        assert_eq!(out[0]["source"], "builtin");
1117        assert_eq!(out[1]["source"], "builtin");
1118        assert_eq!(out[2]["source"], "extension");
1119    }
1120
1121    /// A built-in alias or hidden built-in wins TUI dispatch without showing up
1122    /// as a suggestion, so an extension registering the same name must not be
1123    /// advertised as `source:"extension"`.
1124    #[test]
1125    fn reserved_builtin_aliases_hide_colliding_extension_entries() {
1126        let out = merge_command_list_reserved(
1127            vec![("/help".into(), "help".into())],
1128            vec![
1129                ("q".into(), "quit".into()),
1130                ("context".into(), "context".into()),
1131                ("deploy".into(), "deploy".into()),
1132            ],
1133            vec![],
1134            vec!["/q".into(), "/context".into()],
1135        );
1136        // `/q` and `/context` are unreachable, `/deploy` is not a built-in.
1137        assert_eq!(names(&out), vec!["/help", "/deploy"]);
1138        assert_eq!(out[1]["source"], "extension");
1139    }
1140
1141    fn names(v: &[serde_json::Value]) -> Vec<&str> {
1142        v.iter().map(|c| c["name"].as_str().unwrap()).collect()
1143    }
1144
1145    #[test]
1146    fn builtin_wins_over_a_same_named_extension_command() {
1147        let out = merge_command_list_reserved(
1148            vec![("/model".into(), "built-in".into())],
1149            vec![("/model".into(), "extension".into())],
1150            vec![],
1151            Vec::new(),
1152        );
1153        assert_eq!(names(&out), vec!["/model"]);
1154        assert_eq!(out[0]["source"], "builtin");
1155        assert_eq!(out[0]["description"], "built-in");
1156    }
1157
1158    #[test]
1159    fn extension_and_template_names_get_a_leading_slash() {
1160        let out = merge_command_list_reserved(
1161            vec![],
1162            vec![("deploy".into(), "deploy".into())],
1163            vec![("review".into(), "review".into())],
1164            Vec::new(),
1165        );
1166        assert_eq!(names(&out), vec!["/deploy", "/review"]);
1167        assert_eq!(out[0]["source"], "extension");
1168        assert_eq!(out[1]["source"], "prompt");
1169    }
1170
1171    /// Prompt templates live in neither the built-in registry nor the extension
1172    /// registry, so `ListCommands` has to merge them itself — otherwise a remote
1173    /// `/` completion list is permanently shorter than the terminal's.
1174    #[test]
1175    fn prompt_templates_are_listed_with_their_own_source() {
1176        let out = merge_command_list_reserved(
1177            vec![("/help".into(), "help".into())],
1178            vec![],
1179            vec![("review".into(), "review a diff".into())],
1180            Vec::new(),
1181        );
1182        assert_eq!(names(&out), vec!["/help", "/review"]);
1183        assert_eq!(out[1]["source"], "prompt");
1184        assert_eq!(out[1]["description"], "review a diff");
1185    }
1186
1187    /// A template sharing a built-in name is unreachable in the TUI too, so it
1188    /// must not be reported twice (or as `prompt`).
1189    #[test]
1190    fn a_builtin_name_is_not_reported_as_a_template() {
1191        let out = merge_command_list_reserved(
1192            vec![("/review".into(), "built-in".into())],
1193            vec![],
1194            vec![("review".into(), "template".into())],
1195            Vec::new(),
1196        );
1197        assert_eq!(names(&out), vec!["/review"]);
1198        assert_eq!(out[0]["source"], "builtin");
1199    }
1200
1201    #[test]
1202    fn no_commands_is_an_empty_list_not_an_error() {
1203        assert!(merge_command_list_reserved(vec![], vec![], vec![], Vec::new()).is_empty());
1204    }
1205}