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