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