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