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