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::session::types::{BranchBounds, EntryOrder, EntryQuery};
51use rpi_harness::types::SendMessageOptions;
52use tokio::runtime::Handle;
53
54use crate::session::{default_session_dir, open_session_by_id_in_dir};
55
56/// The `RuntimeActionHost` impl over an `AgentHarness`. Built once per session
57/// in [`crate::session::build`] — constructed **empty** before extension load
58/// (the harness doesn't exist yet), then filled via [`set_harness`](Self::set_harness)
59/// once `AgentHarness::create` succeeds. Carried inside the [`ActionBridge`] as
60/// `Arc<dyn RuntimeActionHost>`.
61///
62/// `runtime` is captured at build time so the bridge can spawn dispatch from
63/// any thread; the impl's async methods run ON that runtime (they are spawned
64/// by the trampoline), so they may freely await.
65pub struct HarnessActionHost {
66    /// Weak binding filled after construction. Each operation upgrades it;
67    /// before construction or after final owner release it returns an error.
68    harness: Arc<OnceLock<WeakAgentHarness>>,
69    /// The auth-filtered catalog (the same list the TUI `/model` selector
70    /// shows). `set_model(id)` resolves an id against this.
71    catalog: RwLock<Vec<rpi_ai::Model>>,
72    /// The session cwd — `new_session`/`fork`/`switch_session` need it to
73    /// locate the session dir.
74    cwd: PathBuf,
75    session_dir: PathBuf,
76    /// Parsed unknown long flags retained for extension consumption.
77    cli_flags: BTreeMap<String, serde_json::Value>,
78    /// The session's plugin-tool context (`cwd` + session id). The session
79    /// runtime actions (`new_session`/`fork`/`switch_session`) swap the session
80    /// the harness serves, and must republish the id here too — a plugin whose
81    /// tool state keyed off the old id would otherwise keep serving the session
82    /// it named before the swap.
83    tool_context: rpi_extensions::ToolCallContext,
84    children: crate::child_agents::ChildAgents,
85    side_questions: crate::side_questions::SideQuestions,
86    #[allow(dead_code)]
87    runtime: Handle,
88}
89
90impl HarnessActionHost {
91    /// Build an **empty** host (no harness yet). The `runtime` is the handle
92    /// the bridge captured (kept only so the impl can name it for future
93    /// direct-spawn needs; the trampoline already spawns on the bridge's
94    /// runtime). Call [`set_harness`](Self::set_harness) once the harness is
95    /// created. Returns `(host, harness_cell)` where `harness_cell` is the
96    /// shared `OnceLock` the caller fills.
97    pub fn new_empty(
98        catalog: Vec<rpi_ai::Model>,
99        cwd: PathBuf,
100        runtime: Handle,
101        cli_flags: BTreeMap<String, serde_json::Value>,
102        tool_context: rpi_extensions::ToolCallContext,
103    ) -> (Self, Arc<OnceLock<WeakAgentHarness>>) {
104        let harness = Arc::new(OnceLock::new());
105        (
106            Self {
107                harness: Arc::clone(&harness),
108                catalog: RwLock::new(catalog),
109                session_dir: default_session_dir(&cwd),
110                cwd,
111                cli_flags,
112                tool_context,
113                children: crate::child_agents::ChildAgents::default(),
114                side_questions: crate::side_questions::SideQuestions::default(),
115                runtime,
116            },
117            harness,
118        )
119    }
120
121    pub fn set_catalog(&self, catalog: Vec<rpi_ai::Model>) {
122        *self
123            .catalog
124            .write()
125            .unwrap_or_else(|error| error.into_inner()) = catalog;
126    }
127
128    pub(crate) fn with_session_dir(mut self, session_dir: PathBuf) -> Self {
129        self.session_dir = session_dir;
130        self
131    }
132
133    /// Fill the harness cell. Call exactly once, immediately after
134    /// `AgentHarness::create` succeeds. Returns the host (for fluent chaining)
135    /// — the caller already holds the `Arc<dyn RuntimeActionHost>` from
136    /// construction; this just populates the cell that host reads.
137    pub fn set_harness(cell: &Arc<OnceLock<WeakAgentHarness>>, harness: Arc<AgentHarness>) {
138        let _ = cell.set(harness.downgrade());
139    }
140
141    /// Pin the harness only for this action. The binding itself must not retain
142    /// tools/providers through the plugin -> keepalive -> host reference cycle.
143    fn harness(&self) -> Result<AgentHarness, String> {
144        self.harness
145            .get()
146            .ok_or_else(|| "runtime action invoked before harness was built".to_string())?
147            .upgrade()
148            .ok_or_else(|| "runtime action invoked after harness was dropped".to_string())
149    }
150
151    /// Resolve `id` (case-insensitive exact, then substring) against the
152    /// catalog. Mirrors the resolver's exact-id-first fallback.
153    fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
154        let catalog = self.catalog.read().ok()?;
155        catalog
156            .iter()
157            .find(|m| m.id.eq_ignore_ascii_case(id))
158            .cloned()
159            .or_else(|| {
160                catalog
161                    .iter()
162                    .find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
163                    .cloned()
164            })
165    }
166}
167
168/// Helper: pull a string field `key` from `args` (object), or return `msg`.
169fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
170    args.get(key)
171        .and_then(|v| v.as_str())
172        .map(|s| s.to_string())
173        .ok_or_else(|| format!("missing string field `{key}` in action args"))
174}
175
176/// Helper: pull an optional string field.
177fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
178    args.get(key)
179        .and_then(|v| v.as_str())
180        .map(|s| s.to_string())
181}
182
183/// Helper: pull a bool field (default `false`).
184fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
185    args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
186}
187
188/// Helper: pull a string array field.
189fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
190    args.get(key)
191        .and_then(|v| v.as_array())
192        .map(|arr| {
193            arr.iter()
194                .filter_map(|v| v.as_str().map(|s| s.to_string()))
195                .collect()
196        })
197        .ok_or_else(|| format!("missing string-array field `{key}` in action args"))
198}
199
200/// Render a `HarnessRunOutcome` as JSON for the plugin. Only the terminal
201/// status + leaf id cross (the final message text is folded to a string; full
202/// assistant content is too rich for a v1 action result).
203pub(crate) fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
204    match outcome {
205        HarnessRunOutcome::Completed {
206            leaf_id,
207            final_entry_id,
208            final_message,
209        } => {
210            serde_json::json!({
211                "status": "completed",
212                "leafId": leaf_id,
213                "finalEntryId": final_entry_id,
214                "text": assistant_text(&final_message),
215            })
216        }
217        HarnessRunOutcome::Aborted {
218            leaf_id,
219            final_entry_id,
220            final_message,
221        } => {
222            serde_json::json!({
223                "status": "aborted",
224                "leafId": leaf_id,
225                "finalEntryId": final_entry_id,
226                "text": assistant_text(&final_message),
227            })
228        }
229        HarnessRunOutcome::Failed {
230            leaf_id,
231            error,
232            final_entry_id,
233            final_message,
234        } => {
235            serde_json::json!({
236                "status": "failed",
237                "leafId": leaf_id,
238                "error": format!("{error:?}"),
239                "finalEntryId": final_entry_id,
240                "text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
241            })
242        }
243        HarnessRunOutcome::Suspended {
244            leaf_id,
245            final_entry_id,
246            ..
247        } => {
248            serde_json::json!({
249                "status": "suspended",
250                "leafId": leaf_id,
251                "finalEntryId": final_entry_id,
252            })
253        }
254    }
255}
256
257/// Accept the SDK's flat action args and Pi-style nested options. Reject mixed
258/// forms instead of silently choosing one set of scheduling instructions.
259fn send_message_options(args: &serde_json::Value) -> Result<SendMessageOptions, String> {
260    let flat = args.get("triggerTurn").is_some() || args.get("deliverAs").is_some();
261    let value = if let Some(options) = args.get("options").filter(|value| !value.is_null()) {
262        if !options.is_object() {
263            return Err("send_message options must be an object".into());
264        }
265        if flat {
266            return Err(
267                "put triggerTurn/deliverAs either in options or at the top level, not both".into(),
268            );
269        }
270        options.clone()
271    } else {
272        let mut fields = serde_json::Map::new();
273        for key in ["triggerTurn", "deliverAs"] {
274            if let Some(value) = args.get(key) {
275                fields.insert(key.into(), value.clone());
276            }
277        }
278        serde_json::Value::Object(fields)
279    };
280    serde_json::from_value(value).map_err(|error| format!("invalid send_message options: {error}"))
281}
282
283fn send_message_input(args: &serde_json::Value) -> Result<rpi_agent::AgentMessage, String> {
284    let timestamp = std::time::SystemTime::now()
285        .duration_since(std::time::UNIX_EPOCH)
286        .unwrap_or_default()
287        .as_millis()
288        .min(i64::MAX as u128) as i64;
289    if let Some(message) = args.get("message") {
290        if message.get("kind").is_some() {
291            return serde_json::from_value(message.clone())
292                .map_err(|error| format!("invalid message: {error}"));
293        }
294        // Pi's custom message input does not include the stored AgentMessage
295        // discriminator. Preserve customType/display/details via its constructor.
296        #[derive(serde::Deserialize)]
297        #[serde(rename_all = "camelCase")]
298        struct CustomInput {
299            custom_type: String,
300            #[serde(default)]
301            content: Option<rpi_ai::types::UserContent>,
302            #[serde(default)]
303            display: bool,
304            #[serde(default)]
305            details: Option<serde_json::Value>,
306        }
307        let input: CustomInput = serde_json::from_value(message.clone())
308            .map_err(|error| format!("invalid custom message: {error}"))?;
309        return Ok(rpi_harness::messages::create_custom_message(
310            &input.custom_type,
311            input
312                .content
313                .unwrap_or(rpi_ai::types::UserContent::Blocks(Vec::new())),
314            input.display,
315            input.details,
316            timestamp,
317        ));
318    }
319    let text =
320        arg_str(args, "text").map_err(|_| "missing `message` or `text` field".to_string())?;
321    Ok(rpi_agent::AgentMessage::User(
322        rpi_ai::types::UserMessage::new(text, timestamp),
323    ))
324}
325
326fn send_message_result(result: SendMessageResult) -> serde_json::Value {
327    match result {
328        SendMessageResult::Appended { entry_id } => {
329            serde_json::json!({"status":"appended", "entryId":entry_id})
330        }
331        SendMessageResult::Queued { entry_id, queue } => {
332            if queue == rpi_harness::session::types::QueueKind::Context {
333                return serde_json::json!({"status":"queued", "entryId":entry_id, "queue":"context"});
334            }
335            let delivery = match queue {
336                rpi_harness::session::types::QueueKind::NextRun => "nextTurn",
337                _ => queue.as_str(),
338            };
339            serde_json::json!({"status":"queued", "entryId":entry_id, "deliverAs":delivery})
340        }
341        SendMessageResult::Run(run) => run_outcome_json(run.outcome),
342    }
343}
344
345/// Extract the concatenated text from an assistant message (the `text` blocks).
346pub(crate) fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
347    msg.content
348        .iter()
349        .filter_map(|b| match b {
350            rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
351            _ => None,
352        })
353        .collect::<Vec<_>>()
354        .join("")
355}
356
357#[async_trait::async_trait]
358impl RuntimeActionHost for HarnessActionHost {
359    fn cancel_side_questions(&self, owner: u64) {
360        self.side_questions.cancel_owner(owner);
361    }
362    fn side_question_revision(&self) -> u64 {
363        self.side_questions.revision()
364    }
365    fn side_question_views(&self) -> Vec<serde_json::Value> {
366        self.side_questions.views()
367    }
368    async fn side_question_ui(
369        &self,
370        owner: u64,
371        thread_id: String,
372        args: serde_json::Value,
373    ) -> Result<serde_json::Value, String> {
374        self.side_questions
375            .ui_handle(&self.harness()?, owner, thread_id, args)
376            .await
377    }
378    async fn side_question(
379        &self,
380        owner: u64,
381        args: serde_json::Value,
382        sink: rpi_extensions::SideQuestionSink,
383    ) -> Result<serde_json::Value, String> {
384        self.side_questions
385            .handle(&self.harness()?, owner, args, sink)
386            .await
387    }
388
389    fn cancel_child_agents(&self, owner: u64) {
390        self.children.cancel_owner(owner);
391    }
392    fn replace_model_catalog(&self, models: Vec<rpi_ai::Model>) {
393        self.set_catalog(models);
394    }
395    async fn get_execution_context(
396        &self,
397        _args: serde_json::Value,
398    ) -> Result<serde_json::Value, String> {
399        let harness = self.harness()?;
400        let mut context = harness.execution_context().map_err(|e| e.to_string())?;
401        if let Some(serde_json::Value::Object(child)) =
402            rpi_agent::tool_execution_scope::execution_context()
403        {
404            context
405                .as_object_mut()
406                .expect("execution context is an object")
407                .extend(child);
408        }
409        context["cwd"] = serde_json::json!(self.cwd);
410        context["sessionId"] = serde_json::json!(harness.session().storage().metadata().id);
411        Ok(context)
412    }
413
414    async fn child_agent(
415        &self,
416        owner: u64,
417        args: serde_json::Value,
418    ) -> Result<serde_json::Value, String> {
419        if rpi_agent::tool_execution_scope::is_child_execution() {
420            return Err("recursive child agents are not allowed".into());
421        }
422        if args.get("op").and_then(serde_json::Value::as_str) == Some("start") {
423            self.children.handle(&self.harness()?, owner, args).await
424        } else {
425            // Reclamation must remain possible after the parent was dropped.
426            self.children.handle_existing(owner, args)
427        }
428    }
429
430    async fn get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
431        let name = arg_str(&args, "name")?;
432        Ok(serde_json::json!({
433            "value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
434        }))
435    }
436
437    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
438        let options = send_message_options(&args)?;
439        let message = send_message_input(&args)?;
440        let lane = self.harness()?.lane("main");
441        let result = lane
442            .send_message(message, options)
443            .await
444            .map_err(|e| e.to_string())?;
445        Ok(send_message_result(result))
446    }
447
448    async fn send_user_message(
449        &self,
450        args: serde_json::Value,
451    ) -> Result<serde_json::Value, String> {
452        let text = arg_str(&args, "text")?;
453        let lane = self.harness()?.lane("main");
454        let result = lane
455            .prompt_text(&text, Vec::new())
456            .await
457            .map_err(|e| e.to_string())?;
458        Ok(run_outcome_json(result.outcome))
459    }
460
461    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
462        // `{"message": <AgentMessage json>}` appends a message entry; OR
463        // `{"customType": "...", "data": {...}}` appends a custom entry. No run
464        // is driven. Messages use the harness boundary writer, whose entry id
465        // may be reserved until the current turn settles. Custom data stays out
466        // of model context unless a caller explicitly configures a projector.
467        if let Some(custom_type) = arg_str_opt(&args, "customType") {
468            let data = args.get("data").cloned();
469            let id = self
470                .harness()?
471                .session()
472                .append_custom_entry(&custom_type, data)
473                .await
474                .map_err(|e| e.to_string())?;
475            return Ok(serde_json::json!({ "entryId": id }));
476        }
477        let msg = args
478            .get("message")
479            .ok_or_else(|| "missing `message` or `customType` field".to_string())?;
480        let message: rpi_agent::AgentMessage =
481            serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
482        let id = self
483            .harness()?
484            .append_message(message)
485            .await
486            .map_err(|e| e.to_string())?;
487        Ok(serde_json::json!({ "entryId": id }))
488    }
489
490    async fn get_session_branch(
491        &self,
492        _args: serde_json::Value,
493    ) -> Result<serde_json::Value, String> {
494        let entries = self
495            .harness()?
496            .find_entries_on_branch(
497                &EntryQuery {
498                    order: Some(EntryOrder::OldestFirst),
499                    ..Default::default()
500                },
501                &BranchBounds::default(),
502            )
503            .await
504            .map_err(|e| e.to_string())?;
505        let entries = entries
506            .iter()
507            .map(serde_json::to_value)
508            .collect::<Result<Vec<_>, _>>()
509            .map_err(|e| format!("serialize session branch: {e}"))?;
510        Ok(serde_json::json!({"entries": entries}))
511    }
512
513    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
514        let name = arg_str(&args, "name")?;
515        self.harness()?
516            .session()
517            .set_name(Some(&name))
518            .await
519            .map_err(|e| e.to_string())?;
520        Ok(serde_json::Value::Null)
521    }
522
523    async fn get_active_tools(
524        &self,
525        _args: serde_json::Value,
526    ) -> Result<serde_json::Value, String> {
527        let lane = self.harness()?.lane("main");
528        let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
529        Ok(serde_json::json!({ "tools": tools }))
530    }
531
532    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
533        let tools = arg_str_array(&args, "tools")?;
534        let lane = self.harness()?.lane("main");
535        lane.set_active_tools(tools)
536            .await
537            .map_err(|e| e.to_string())?;
538        Ok(serde_json::Value::Null)
539    }
540
541    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
542        let id = arg_str(&args, "model")?;
543        let model = self
544            .resolve_model(&id)
545            .ok_or_else(|| format!("model `{id}` not in catalog"))?;
546        let lane = self.harness()?.lane("main");
547        lane.set_model(model.clone())
548            .await
549            .map_err(|e| e.to_string())?;
550        Ok(serde_json::json!({ "model": model.id }))
551    }
552
553    async fn get_thinking_level(
554        &self,
555        _args: serde_json::Value,
556    ) -> Result<serde_json::Value, String> {
557        let lane = self.harness()?.lane("main");
558        let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
559        Ok(serde_json::json!({ "level": level }))
560    }
561
562    async fn set_thinking_level(
563        &self,
564        args: serde_json::Value,
565    ) -> Result<serde_json::Value, String> {
566        let level_val = args
567            .get("level")
568            .ok_or_else(|| "missing `level` field".to_string())?;
569        let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
570            serde_json::from_value(serde_json::Value::String(s.to_string()))
571                .map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
572        } else {
573            serde_json::from_value(level_val.clone())
574                .map_err(|e| format!("invalid thinking level: {e}"))?
575        };
576        let lane = self.harness()?.lane("main");
577        lane.set_thinking_level(level)
578            .await
579            .map_err(|e| e.to_string())?;
580        Ok(serde_json::Value::Null)
581    }
582
583    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
584        let custom = arg_str_opt(&args, "customInstructions");
585        let lane = self.harness()?.lane("main");
586        let result = lane
587            .compact(custom.as_deref())
588            .await
589            .map_err(|e| e.to_string())?;
590        Ok(
591            serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }),
592        )
593    }
594
595    async fn get_system_prompt(
596        &self,
597        _args: serde_json::Value,
598    ) -> Result<serde_json::Value, String> {
599        let prompt = self
600            .harness()?
601            .get_system_prompt()
602            .await
603            .map_err(|e| e.to_string())?;
604        Ok(serde_json::json!({ "prompt": prompt }))
605    }
606
607    async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
608        let harness = self.harness()?;
609        let cwd_str = self.cwd.to_string_lossy().to_string();
610        let dir = &self.session_dir;
611        std::fs::create_dir_all(dir)
612            .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
613        let session = crate::session::create_jsonl_session(dir, &cwd_str)
614            .await
615            .map_err(|e| format!("create session: {e}"))?;
616        let id = session.storage().metadata().id.clone();
617        crate::session::set_active_session(&harness, session, Some(&self.tool_context)).await?;
618        Ok(serde_json::json!({ "sessionId": id }))
619    }
620
621    async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
622        let harness = self.harness()?;
623        let cwd_str = self.cwd.to_string_lossy().to_string();
624        let new_session =
625            crate::session::fork_session_storage(&harness, &cwd_str, &self.session_dir)
626                .await
627                .map_err(|e| format!("fork session: {e}"))?;
628        let id = new_session.storage().metadata().id.clone();
629        crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
630        Ok(serde_json::json!({ "sessionId": id }))
631    }
632
633    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
634        let target_id = arg_str_opt(&args, "targetId");
635        let summarize = arg_bool(&args, "summarize");
636        let custom = arg_str_opt(&args, "customInstructions");
637        let label = arg_str_opt(&args, "label");
638        let lane = self.harness()?.lane("main");
639        let result = lane
640            .navigate_tree(
641                target_id.as_deref(),
642                summarize,
643                custom.as_deref(),
644                label.as_deref(),
645            )
646            .await
647            .map_err(|e| e.to_string())?;
648        let status = match &result.outcome {
649            NavigationOutcome::Completed { .. } => "completed",
650            NavigationOutcome::Declined { .. } => "declined",
651            NavigationOutcome::Aborted { .. } => "aborted",
652            NavigationOutcome::Failed { .. } => "failed",
653        };
654        Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
655    }
656
657    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
658        let harness = self.harness()?;
659        let id = arg_str(&args, "id")?;
660        let cwd_str = self.cwd.to_string_lossy().to_string();
661        let new_session: Session = open_session_by_id_in_dir(&id, &cwd_str, &self.session_dir)
662            .await
663            .map_err(|e| e.to_string())?;
664        let new_id = new_session.storage().metadata().id.clone();
665        crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
666        Ok(serde_json::json!({ "sessionId": new_id }))
667    }
668
669    async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
670        // Reached only when `ActionBridge.reload` is `None` (no /reload wired).
671        // B5d wires the reload callback at the bridge layer; this host impl is
672        // the "not configured" fallback.
673        Err("reload not configured (no /reload callback on this bridge)".to_string())
674    }
675}
676
677#[cfg(test)]
678mod lifetime_tests;
679
680#[cfg(test)]
681mod send_message_tests {
682    use super::*;
683    use rpi_harness::types::MessageDelivery;
684
685    #[test]
686    fn options_accept_flat_and_nested_forms_with_pi_defaults() {
687        assert_eq!(
688            send_message_options(&serde_json::json!({"text":"context"})).unwrap(),
689            SendMessageOptions::default()
690        );
691        let expected = SendMessageOptions {
692            trigger_turn: Some(false),
693            deliver_as: MessageDelivery::FollowUp,
694        };
695        for args in [
696            serde_json::json!({"text":"context", "triggerTurn":false, "deliverAs":"followUp"}),
697            serde_json::json!({"text":"context", "options":{"triggerTurn":false, "deliverAs":"followUp"}}),
698        ] {
699            assert_eq!(send_message_options(&args).unwrap(), expected);
700        }
701    }
702
703    #[test]
704    fn options_reject_invalid_or_ambiguous_scheduling_instructions() {
705        for args in [
706            serde_json::json!({"triggerTurn":"false"}),
707            serde_json::json!({"deliverAs":"follow-up"}),
708            serde_json::json!({"options":{"typo":true}}),
709            serde_json::json!({"options":[], "text":"context"}),
710            serde_json::json!({"options":{"triggerTurn":true}, "triggerTurn":false}),
711        ] {
712            assert!(send_message_options(&args).is_err(), "{args}");
713        }
714    }
715
716    #[test]
717    fn pi_custom_input_preserves_content_and_display_metadata() {
718        let message = send_message_input(&serde_json::json!({"message":{
719            "customType":"goal", "content":"goal state", "display":true, "details":{"step":2}
720        }}))
721        .unwrap();
722        let rpi_agent::AgentMessage::Custom(custom) = &message else {
723            panic!("custom")
724        };
725        assert_eq!(
726            custom.content,
727            vec![rpi_ai::types::Content::text("goal state")]
728        );
729        let data = rpi_harness::messages::custom_data(&message).unwrap();
730        assert_eq!(data.custom_type, "goal");
731        assert!(data.display);
732        assert_eq!(data.details, Some(serde_json::json!({"step":2})));
733        // The existing stored AgentMessage input remains supported.
734        let stored = serde_json::to_value(&message).unwrap();
735        assert_eq!(
736            send_message_input(&serde_json::json!({"message":stored})).unwrap(),
737            message
738        );
739    }
740
741    #[test]
742    fn missing_custom_content_is_empty_and_queued_results_use_pi_delivery_names() {
743        let message = send_message_input(
744            &serde_json::json!({"message":{"customType":"goal", "content":null}}),
745        )
746        .unwrap();
747        assert!(
748            matches!(message, rpi_agent::AgentMessage::Custom(custom) if custom.content.is_empty())
749        );
750        assert_eq!(
751            send_message_result(SendMessageResult::Queued {
752                entry_id: "reserved".into(),
753                queue: rpi_harness::session::types::QueueKind::NextRun,
754            }),
755            serde_json::json!({"status":"queued", "entryId":"reserved", "deliverAs":"nextTurn"})
756        );
757    }
758}