1use 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
55fn merge_command_list_reserved(
77 builtin: Vec<(String, String)>,
78 extension: Vec<(String, String)>,
79 templates: Vec<(String, String)>,
80 reserved: Vec<String>,
81) -> Vec<serde_json::Value> {
82 let mut reserved_names = std::collections::BTreeSet::new();
88 for name in reserved {
89 let name = if !name.starts_with('/') {
90 format!("/{name}")
91 } else {
92 name
93 };
94 reserved_names.insert(name);
95 }
96 let mut seen = std::collections::BTreeSet::new();
97 let mut out: Vec<serde_json::Value> = Vec::new();
98 for (source, commands) in [
99 ("builtin", builtin),
100 ("extension", extension),
101 ("prompt", templates),
102 ] {
103 for (name, description) in commands {
104 let name = if source != "builtin" && !name.starts_with('/') {
105 format!("/{name}")
106 } else {
107 name
108 };
109 if source != "builtin" && reserved_names.contains(&name) {
110 continue;
111 }
112 if !seen.insert(name.clone()) {
113 continue;
114 }
115 out.push(serde_json::json!({
116 "name": name,
117 "description": description,
118 "source": source,
119 }));
120 }
121 }
122 out
123}
124
125pub struct HarnessActionHost {
135 harness: Arc<OnceLock<WeakAgentHarness>>,
138 catalog: RwLock<Vec<rpi_ai::Model>>,
141 cwd: PathBuf,
144 session_dir: PathBuf,
145 fresh_session_only: bool,
146 cli_flags: BTreeMap<String, serde_json::Value>,
148 tool_context: rpi_extensions::ToolCallContext,
154 children: crate::child_agents::ChildAgents,
155 side_questions: crate::side_questions::SideQuestions,
156 #[allow(dead_code)]
157 runtime: Handle,
158 registry: std::sync::RwLock<Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>>,
166}
167
168impl HarnessActionHost {
169 pub fn new_empty(
176 catalog: Vec<rpi_ai::Model>,
177 cwd: PathBuf,
178 runtime: Handle,
179 cli_flags: BTreeMap<String, serde_json::Value>,
180 tool_context: rpi_extensions::ToolCallContext,
181 ) -> (Self, Arc<OnceLock<WeakAgentHarness>>) {
182 let harness = Arc::new(OnceLock::new());
183 (
184 Self {
185 harness: Arc::clone(&harness),
186 catalog: RwLock::new(catalog),
187 session_dir: default_session_dir(&cwd),
188 fresh_session_only: false,
189 cwd,
190 cli_flags,
191 tool_context,
192 children: crate::child_agents::ChildAgents::default(),
193 side_questions: crate::side_questions::SideQuestions::default(),
194 runtime,
195 registry: std::sync::RwLock::new(None),
196 },
197 harness,
198 )
199 }
200
201 pub fn set_catalog(&self, catalog: Vec<rpi_ai::Model>) {
202 *self
203 .catalog
204 .write()
205 .unwrap_or_else(|error| error.into_inner()) = catalog;
206 }
207
208 pub(crate) fn with_fresh_session_only(mut self, locked: bool) -> Self {
209 self.fresh_session_only = locked;
210 self
211 }
212 fn allow_session_routing(&self) -> Result<(), String> {
213 if self.fresh_session_only {
214 Err("experimental native print session forbids session routing actions".into())
215 } else {
216 Ok(())
217 }
218 }
219
220 pub(crate) fn with_session_dir(mut self, session_dir: PathBuf) -> Self {
221 self.session_dir = session_dir;
222 self
223 }
224
225 pub(crate) fn publish_registry_impl(
231 &self,
232 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
233 ) {
234 if let Ok(mut slot) = self.registry.write() {
235 *slot = snapshot;
236 }
237 }
238
239 fn registry_snapshot(&self) -> Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>> {
240 self.registry.read().ok().and_then(|slot| slot.clone())
241 }
242
243 pub fn set_harness(cell: &Arc<OnceLock<WeakAgentHarness>>, harness: Arc<AgentHarness>) {
248 let _ = cell.set(harness.downgrade());
249 }
250
251 fn harness(&self) -> Result<AgentHarness, String> {
254 self.harness
255 .get()
256 .ok_or_else(|| "runtime action invoked before harness was built".to_string())?
257 .upgrade()
258 .ok_or_else(|| "runtime action invoked after harness was dropped".to_string())
259 }
260
261 fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
264 let catalog = self.catalog.read().ok()?;
265 catalog
266 .iter()
267 .find(|m| m.id.eq_ignore_ascii_case(id))
268 .cloned()
269 .or_else(|| {
270 catalog
271 .iter()
272 .find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
273 .cloned()
274 })
275 }
276}
277
278pub(crate) fn queued_message_payloads(
286 messages: &[rpi_agent::AgentMessage],
287) -> Vec<serde_json::Value> {
288 messages
289 .iter()
290 .map(|message| {
291 let (text, images) = match message {
292 rpi_agent::AgentMessage::User(user) => match &user.content {
293 rpi_ai::types::UserContent::Text(text) => (text.clone(), Vec::new()),
294 rpi_ai::types::UserContent::Blocks(blocks) => {
295 let text = blocks
296 .iter()
297 .filter_map(|block| match block {
298 rpi_ai::types::Content::Text(text) => Some(text.text.as_str()),
299 _ => None,
300 })
301 .collect::<Vec<_>>()
302 .join("\n");
303 let collected: Vec<_> = blocks
304 .iter()
305 .filter_map(|block| match block {
306 rpi_ai::types::Content::Image(image) => Some(image.clone()),
307 _ => None,
308 })
309 .collect();
310 (text, image_payloads(&collected))
311 }
312 },
313 _ => (String::new(), Vec::new()),
314 };
315 serde_json::json!({ "text": text, "images": images })
316 })
317 .filter(|entry| {
318 entry["text"].as_str().is_some_and(|t| !t.trim().is_empty())
319 || entry["images"].as_array().is_some_and(|i| !i.is_empty())
320 })
321 .collect()
322}
323
324fn image_payloads(images: &[rpi_ai::types::ImageContent]) -> Vec<serde_json::Value> {
326 images
327 .iter()
328 .map(|image| {
329 serde_json::json!({
330 "mediaType": image.mime_type,
331 "data": image.data,
332 })
333 })
334 .collect()
335}
336
337fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
339 args.get(key)
340 .and_then(|v| v.as_str())
341 .map(|s| s.to_string())
342 .ok_or_else(|| format!("missing string field `{key}` in action args"))
343}
344
345fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
347 args.get(key)
348 .and_then(|v| v.as_str())
349 .map(|s| s.to_string())
350}
351
352fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
354 args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
355}
356
357fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
359 args.get(key)
360 .and_then(|v| v.as_array())
361 .map(|arr| {
362 arr.iter()
363 .filter_map(|v| v.as_str().map(|s| s.to_string()))
364 .collect()
365 })
366 .ok_or_else(|| format!("missing string-array field `{key}` in action args"))
367}
368
369pub(crate) fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
373 match outcome {
374 HarnessRunOutcome::Completed {
375 leaf_id,
376 final_entry_id,
377 final_message,
378 } => {
379 serde_json::json!({
380 "status": "completed",
381 "leafId": leaf_id,
382 "finalEntryId": final_entry_id,
383 "text": assistant_text(&final_message),
384 })
385 }
386 HarnessRunOutcome::Aborted {
387 leaf_id,
388 final_entry_id,
389 final_message,
390 } => {
391 serde_json::json!({
392 "status": "aborted",
393 "leafId": leaf_id,
394 "finalEntryId": final_entry_id,
395 "text": assistant_text(&final_message),
396 })
397 }
398 HarnessRunOutcome::Failed {
399 leaf_id,
400 error,
401 final_entry_id,
402 final_message,
403 } => {
404 serde_json::json!({
405 "status": "failed",
406 "leafId": leaf_id,
407 "error": format!("{error:?}"),
408 "finalEntryId": final_entry_id,
409 "text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
410 })
411 }
412 HarnessRunOutcome::Suspended {
413 leaf_id,
414 final_entry_id,
415 ..
416 } => {
417 serde_json::json!({
418 "status": "suspended",
419 "leafId": leaf_id,
420 "finalEntryId": final_entry_id,
421 })
422 }
423 }
424}
425
426fn send_message_options(args: &serde_json::Value) -> Result<SendMessageOptions, String> {
429 let flat = args.get("triggerTurn").is_some() || args.get("deliverAs").is_some();
430 let value = if let Some(options) = args.get("options").filter(|value| !value.is_null()) {
431 if !options.is_object() {
432 return Err("send_message options must be an object".into());
433 }
434 if flat {
435 return Err(
436 "put triggerTurn/deliverAs either in options or at the top level, not both".into(),
437 );
438 }
439 options.clone()
440 } else {
441 let mut fields = serde_json::Map::new();
442 for key in ["triggerTurn", "deliverAs"] {
443 if let Some(value) = args.get(key) {
444 fields.insert(key.into(), value.clone());
445 }
446 }
447 serde_json::Value::Object(fields)
448 };
449 serde_json::from_value(value).map_err(|error| format!("invalid send_message options: {error}"))
450}
451
452fn send_message_input(args: &serde_json::Value) -> Result<rpi_agent::AgentMessage, String> {
453 let timestamp = std::time::SystemTime::now()
454 .duration_since(std::time::UNIX_EPOCH)
455 .unwrap_or_default()
456 .as_millis()
457 .min(i64::MAX as u128) as i64;
458 if let Some(message) = args.get("message") {
459 if message.get("kind").is_some() {
460 return serde_json::from_value(message.clone())
461 .map_err(|error| format!("invalid message: {error}"));
462 }
463 #[derive(serde::Deserialize)]
466 #[serde(rename_all = "camelCase")]
467 struct CustomInput {
468 custom_type: String,
469 #[serde(default)]
470 content: Option<rpi_ai::types::UserContent>,
471 #[serde(default)]
472 display: bool,
473 #[serde(default)]
474 details: Option<serde_json::Value>,
475 }
476 let input: CustomInput = serde_json::from_value(message.clone())
477 .map_err(|error| format!("invalid custom message: {error}"))?;
478 return Ok(rpi_harness::messages::create_custom_message(
479 &input.custom_type,
480 input
481 .content
482 .unwrap_or(rpi_ai::types::UserContent::Blocks(Vec::new())),
483 input.display,
484 input.details,
485 timestamp,
486 ));
487 }
488 let text =
489 arg_str(args, "text").map_err(|_| "missing `message` or `text` field".to_string())?;
490 Ok(rpi_agent::AgentMessage::User(
491 rpi_ai::types::UserMessage::new(text, timestamp),
492 ))
493}
494
495fn send_message_result(result: SendMessageResult) -> serde_json::Value {
496 match result {
497 SendMessageResult::Appended { entry_id } => {
498 serde_json::json!({"status":"appended", "entryId":entry_id})
499 }
500 SendMessageResult::Queued { entry_id, queue } => {
501 if queue == rpi_harness::session::types::QueueKind::Context {
502 return serde_json::json!({"status":"queued", "entryId":entry_id, "queue":"context"});
503 }
504 let delivery = match queue {
505 rpi_harness::session::types::QueueKind::NextRun => "nextTurn",
506 _ => queue.as_str(),
507 };
508 serde_json::json!({"status":"queued", "entryId":entry_id, "deliverAs":delivery})
509 }
510 SendMessageResult::Run(run) => run_outcome_json(run.outcome),
511 }
512}
513
514pub(crate) fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
516 msg.content
517 .iter()
518 .filter_map(|b| match b {
519 rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
520 _ => None,
521 })
522 .collect::<Vec<_>>()
523 .join("")
524}
525
526#[async_trait::async_trait]
527impl RuntimeActionHost for HarnessActionHost {
528 fn cancel_side_questions(&self, owner: u64) {
529 self.side_questions.cancel_owner(owner);
530 }
531 fn side_question_revision(&self) -> u64 {
532 self.side_questions.revision()
533 }
534 fn side_question_views(&self) -> Vec<serde_json::Value> {
535 self.side_questions.views()
536 }
537 async fn side_question_ui(
538 &self,
539 owner: u64,
540 thread_id: String,
541 args: serde_json::Value,
542 ) -> Result<serde_json::Value, String> {
543 self.side_questions
544 .ui_handle(&self.harness()?, owner, thread_id, args)
545 .await
546 }
547 async fn side_question(
548 &self,
549 owner: u64,
550 args: serde_json::Value,
551 sink: rpi_extensions::SideQuestionSink,
552 ) -> Result<serde_json::Value, String> {
553 self.side_questions
554 .handle(&self.harness()?, owner, args, sink)
555 .await
556 }
557
558 fn cancel_child_agents(&self, owner: u64) {
559 self.children.cancel_owner(owner);
560 }
561 fn replace_model_catalog(&self, models: Vec<rpi_ai::Model>) {
562 self.set_catalog(models);
563 }
564 async fn list_commands(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
565 let builtin = crate::interactive_tui::build_builtin_commands_for_abi();
566 let extension: Vec<(String, String)> = self
567 .registry_snapshot()
568 .as_deref()
569 .map(|snapshot| {
570 snapshot
571 .commands()
572 .iter()
573 .map(|c| (c.name.clone(), c.description.clone()))
574 .collect()
575 })
576 .unwrap_or_default();
577 let templates: Vec<(String, String)> = match self.harness() {
582 Ok(harness) => match harness.get_resources().await {
583 Ok(resources) => resources
584 .prompt_templates
585 .unwrap_or_default()
586 .into_iter()
587 .map(|template| {
588 (
589 template.name,
590 template
591 .description
592 .unwrap_or_else(|| "Expand prompt template".to_string()),
593 )
594 })
595 .collect(),
596 Err(_) => Vec::new(),
597 },
598 Err(_) => Vec::new(),
599 };
600 Ok(serde_json::json!({
601 "commands": merge_command_list_reserved(
602 builtin,
603 extension,
604 templates,
605 crate::interactive_tui::builtin_reserved_names(),
606 )
607 }))
608 }
609
610 fn publish_registry(&self, snapshot: std::sync::Arc<rpi_extensions::RegistrySnapshot>) {
611 self.publish_registry_impl(Some(snapshot));
612 }
613
614 fn publish_registry_opt(
615 &self,
616 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
617 ) {
618 self.publish_registry_impl(snapshot);
619 }
620
621 async fn abort(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
622 let harness = self.harness()?;
638 let result = harness
639 .abort_reclaiming(true)
640 .await
641 .map_err(|error| error.to_string())?;
642 Ok(serde_json::json!({
643 "aborted": !result.run_id.is_empty(),
647 "runId": if result.run_id.is_empty() {
648 serde_json::Value::Null
649 } else {
650 serde_json::json!(result.run_id)
651 },
652 "steer": queued_message_payloads(&result.steer),
653 "followUp": queued_message_payloads(&result.follow_up),
654 }))
655 }
656
657 fn stop_round(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
658 self.harness()?.request_stop_round();
659 Ok(serde_json::json!({ "requested": true }))
660 }
661
662 async fn get_execution_context(
663 &self,
664 _args: serde_json::Value,
665 ) -> Result<serde_json::Value, String> {
666 let harness = self.harness()?;
667 let mut context = harness.execution_context().map_err(|e| e.to_string())?;
668 if let Some(serde_json::Value::Object(child)) =
669 rpi_agent::tool_execution_scope::execution_context()
670 {
671 context
672 .as_object_mut()
673 .expect("execution context is an object")
674 .extend(child);
675 }
676 context["cwd"] = serde_json::json!(self.cwd);
677 context["sessionId"] = serde_json::json!(harness.session().storage().metadata().id);
678 Ok(context)
679 }
680
681 async fn child_agent(
682 &self,
683 owner: u64,
684 args: serde_json::Value,
685 ) -> Result<serde_json::Value, String> {
686 if rpi_agent::tool_execution_scope::is_child_execution() {
687 return Err("recursive child agents are not allowed".into());
688 }
689 if args.get("op").and_then(serde_json::Value::as_str) == Some("start") {
690 self.children.handle(&self.harness()?, owner, args).await
691 } else {
692 self.children.handle_existing(owner, args)
694 }
695 }
696
697 async fn get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
698 let name = arg_str(&args, "name")?;
699 Ok(serde_json::json!({
700 "value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
701 }))
702 }
703
704 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
705 let options = send_message_options(&args)?;
706 let message = send_message_input(&args)?;
707 let lane = self.harness()?.lane("main");
708 let result = lane
709 .send_message(message, options)
710 .await
711 .map_err(|e| e.to_string())?;
712 Ok(send_message_result(result))
713 }
714
715 async fn send_user_message(
716 &self,
717 args: serde_json::Value,
718 ) -> Result<serde_json::Value, String> {
719 let text = arg_str(&args, "text")?;
720 let lane = self.harness()?.lane("main");
721 let mut options = send_message_options(&args)?;
737 if options.trigger_turn.is_none() {
738 options.trigger_turn = Some(true);
739 }
740 let message = rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage::new(
741 text,
742 std::time::SystemTime::now()
743 .duration_since(std::time::UNIX_EPOCH)
744 .unwrap_or_default()
745 .as_millis()
746 .min(i64::MAX as u128) as i64,
747 ));
748 let result = lane
749 .send_message(message, options)
750 .await
751 .map_err(|e| e.to_string())?;
752 Ok(send_message_result(result))
753 }
754
755 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
756 if let Some(expected) = args.get("expected") {
762 let guard = serde_json::from_value(expected.clone())
763 .map_err(|error| format!("invalid AppendEntry expected guard: {error}"))?;
764 let entry = if let Some(custom_type) = arg_str_opt(&args, "customType") {
765 if args.get("message").is_some() || args.get("outboxKey").is_some() {
766 return Err("scoped custom entry cannot include message/outboxKey".into());
767 }
768 crate::resource_selection::validate_public_entry(&custom_type, args.get("data"))?;
769 rpi_harness::agent_harness::ScopedEntry::Custom {
770 custom_type,
771 data: args.get("data").cloned(),
772 }
773 } else {
774 let message =
775 serde_json::from_value(args.get("message").cloned().ok_or("missing message")?)
776 .map_err(|error| format!("invalid message: {error}"))?;
777 let key = arg_str(&args, "outboxKey")?;
778 rpi_harness::agent_harness::ScopedEntry::Notice { message, key }
779 };
780 let id = self
781 .harness()?
782 .append_scoped_entry(&guard, entry)
783 .await
784 .map_err(|error| error.to_string())?;
785 return Ok(serde_json::json!({"entryId":id, "scoped":true}));
786 }
787 if args.get("outboxKey").is_some() {
788 return Err("outboxKey requires an expected guard".into());
789 }
790 if let Some(custom_type) = arg_str_opt(&args, "customType") {
791 crate::resource_selection::validate_public_entry(&custom_type, args.get("data"))?;
792 let data = args.get("data").cloned();
793 let id = self
794 .harness()?
795 .append_custom_entry(&custom_type, data)
796 .await
797 .map_err(|e| e.to_string())?;
798 return Ok(serde_json::json!({ "entryId": id }));
799 }
800 let msg = args
801 .get("message")
802 .ok_or_else(|| "missing `message` or `customType` field".to_string())?;
803 let message: rpi_agent::AgentMessage =
804 serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
805 let id = self
806 .harness()?
807 .append_message(message)
808 .await
809 .map_err(|e| e.to_string())?;
810 Ok(serde_json::json!({ "entryId": id }))
811 }
812
813 async fn get_session_branch(
814 &self,
815 args: serde_json::Value,
816 ) -> Result<serde_json::Value, String> {
817 let request = crate::modes::branch_entry_query(&args)?;
818 let (entries, session_id, leaf_id) =
819 crate::modes::branch_snapshot(&self.harness()?, &request).await?;
820 let entries = entries
821 .iter()
822 .map(serde_json::to_value)
823 .collect::<Result<Vec<_>, _>>()
824 .map_err(|e| format!("serialize session branch: {e}"))?;
825 Ok(serde_json::json!({
826 "entries": entries,
827 "count": entries.len(),
828 "filtered": true,
829 "sessionId": session_id,
830 "leafId": leaf_id,
831 "scopedAppend": true,
832 }))
833 }
834
835 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
836 let name = arg_str(&args, "name")?;
837 self.harness()?
838 .session()
839 .set_name(Some(&name))
840 .await
841 .map_err(|e| e.to_string())?;
842 Ok(serde_json::Value::Null)
843 }
844
845 async fn get_active_tools(
846 &self,
847 _args: serde_json::Value,
848 ) -> Result<serde_json::Value, String> {
849 let lane = self.harness()?.lane("main");
850 let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
851 Ok(serde_json::json!({ "tools": tools }))
852 }
853
854 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
855 let tools = arg_str_array(&args, "tools")?;
856 let lane = self.harness()?.lane("main");
857 lane.set_active_tools(tools)
858 .await
859 .map_err(|e| e.to_string())?;
860 Ok(serde_json::Value::Null)
861 }
862
863 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
864 let id = arg_str(&args, "model")?;
865 let model = self
866 .resolve_model(&id)
867 .ok_or_else(|| format!("model `{id}` not in catalog"))?;
868 let lane = self.harness()?.lane("main");
869 lane.set_model(model.clone())
870 .await
871 .map_err(|e| e.to_string())?;
872 Ok(serde_json::json!({ "model": model.id }))
873 }
874
875 async fn get_thinking_level(
876 &self,
877 _args: serde_json::Value,
878 ) -> Result<serde_json::Value, String> {
879 let lane = self.harness()?.lane("main");
880 let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
881 Ok(serde_json::json!({ "level": level }))
882 }
883
884 async fn set_thinking_level(
885 &self,
886 args: serde_json::Value,
887 ) -> Result<serde_json::Value, String> {
888 let level_val = args
889 .get("level")
890 .ok_or_else(|| "missing `level` field".to_string())?;
891 let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
892 serde_json::from_value(serde_json::Value::String(s.to_string()))
893 .map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
894 } else {
895 serde_json::from_value(level_val.clone())
896 .map_err(|e| format!("invalid thinking level: {e}"))?
897 };
898 let lane = self.harness()?.lane("main");
899 lane.set_thinking_level(level)
900 .await
901 .map_err(|e| e.to_string())?;
902 Ok(serde_json::Value::Null)
903 }
904
905 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
906 let custom = arg_str_opt(&args, "customInstructions");
907 let lane = self.harness()?.lane("main");
908 let result = lane
909 .compact(custom.as_deref())
910 .await
911 .map_err(|e| e.to_string())?;
912 Ok(
913 serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }),
914 )
915 }
916
917 async fn get_system_prompt(
918 &self,
919 _args: serde_json::Value,
920 ) -> Result<serde_json::Value, String> {
921 let prompt = self
922 .harness()?
923 .get_system_prompt()
924 .await
925 .map_err(|e| e.to_string())?;
926 Ok(serde_json::json!({ "prompt": prompt }))
927 }
928
929 async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
930 self.allow_session_routing()?;
931 let harness = self.harness()?;
932 let cwd_str = self.cwd.to_string_lossy().to_string();
933 let dir = &self.session_dir;
934 std::fs::create_dir_all(dir)
935 .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
936 let session = crate::session::create_jsonl_session(dir, &cwd_str)
937 .await
938 .map_err(|e| format!("create session: {e}"))?;
939 let id = session.storage().metadata().id.clone();
940 crate::session::set_active_session(&harness, session, Some(&self.tool_context)).await?;
941 Ok(serde_json::json!({ "sessionId": id }))
942 }
943
944 async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
945 self.allow_session_routing()?;
946 let harness = self.harness()?;
947 let cwd_str = self.cwd.to_string_lossy().to_string();
948 let new_session =
949 crate::session::fork_session_storage(&harness, &cwd_str, &self.session_dir)
950 .await
951 .map_err(|e| format!("fork session: {e}"))?;
952 let id = new_session.storage().metadata().id.clone();
953 crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
954 Ok(serde_json::json!({ "sessionId": id }))
955 }
956
957 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
958 self.allow_session_routing()?;
959 let target_id = arg_str_opt(&args, "targetId");
960 let summarize = arg_bool(&args, "summarize");
961 let custom = arg_str_opt(&args, "customInstructions");
962 let label = arg_str_opt(&args, "label");
963 let lane = self.harness()?.lane("main");
964 let result = lane
965 .navigate_tree(
966 target_id.as_deref(),
967 summarize,
968 custom.as_deref(),
969 label.as_deref(),
970 )
971 .await
972 .map_err(|e| e.to_string())?;
973 let status = match &result.outcome {
974 NavigationOutcome::Completed { .. } => "completed",
975 NavigationOutcome::Declined { .. } => "declined",
976 NavigationOutcome::Aborted { .. } => "aborted",
977 NavigationOutcome::Failed { .. } => "failed",
978 };
979 Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
980 }
981
982 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
983 self.allow_session_routing()?;
984 let harness = self.harness()?;
985 let id = arg_str(&args, "id")?;
986 let cwd_str = self.cwd.to_string_lossy().to_string();
987 let new_session: Session = open_session_by_id_in_dir(&id, &cwd_str, &self.session_dir)
988 .await
989 .map(|restored| restored.session)
990 .map_err(|e| e.to_string())?;
991 let new_id = new_session.storage().metadata().id.clone();
992 crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
993 Ok(serde_json::json!({ "sessionId": new_id }))
994 }
995
996 async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
997 Err("reload not configured (no /reload callback on this bridge)".to_string())
1001 }
1002}
1003
1004#[cfg(test)]
1005mod lifetime_tests;
1006
1007#[cfg(test)]
1008mod send_message_tests {
1009 use super::*;
1010 use rpi_harness::types::MessageDelivery;
1011
1012 #[test]
1013 fn options_accept_flat_and_nested_forms_with_pi_defaults() {
1014 assert_eq!(
1015 send_message_options(&serde_json::json!({"text":"context"})).unwrap(),
1016 SendMessageOptions::default()
1017 );
1018 let expected = SendMessageOptions {
1019 trigger_turn: Some(false),
1020 deliver_as: MessageDelivery::FollowUp,
1021 };
1022 for args in [
1023 serde_json::json!({"text":"context", "triggerTurn":false, "deliverAs":"followUp"}),
1024 serde_json::json!({"text":"context", "options":{"triggerTurn":false, "deliverAs":"followUp"}}),
1025 ] {
1026 assert_eq!(send_message_options(&args).unwrap(), expected);
1027 }
1028 }
1029
1030 #[test]
1031 fn options_reject_invalid_or_ambiguous_scheduling_instructions() {
1032 for args in [
1033 serde_json::json!({"triggerTurn":"false"}),
1034 serde_json::json!({"deliverAs":"follow-up"}),
1035 serde_json::json!({"options":{"typo":true}}),
1036 serde_json::json!({"options":[], "text":"context"}),
1037 serde_json::json!({"options":{"triggerTurn":true}, "triggerTurn":false}),
1038 ] {
1039 assert!(send_message_options(&args).is_err(), "{args}");
1040 }
1041 }
1042
1043 #[test]
1044 fn pi_custom_input_preserves_content_and_display_metadata() {
1045 let message = send_message_input(&serde_json::json!({"message":{
1046 "customType":"goal", "content":"goal state", "display":true, "details":{"step":2}
1047 }}))
1048 .unwrap();
1049 let rpi_agent::AgentMessage::Custom(custom) = &message else {
1050 panic!("custom")
1051 };
1052 assert_eq!(
1053 custom.content,
1054 vec![rpi_ai::types::Content::text("goal state")]
1055 );
1056 let data = rpi_harness::messages::custom_data(&message).unwrap();
1057 assert_eq!(data.custom_type, "goal");
1058 assert!(data.display);
1059 assert_eq!(data.details, Some(serde_json::json!({"step":2})));
1060 let stored = serde_json::to_value(&message).unwrap();
1062 assert_eq!(
1063 send_message_input(&serde_json::json!({"message":stored})).unwrap(),
1064 message
1065 );
1066 }
1067
1068 #[test]
1069 fn missing_custom_content_is_empty_and_queued_results_use_pi_delivery_names() {
1070 let message = send_message_input(
1071 &serde_json::json!({"message":{"customType":"goal", "content":null}}),
1072 )
1073 .unwrap();
1074 assert!(
1075 matches!(message, rpi_agent::AgentMessage::Custom(custom) if custom.content.is_empty())
1076 );
1077 assert_eq!(
1078 send_message_result(SendMessageResult::Queued {
1079 entry_id: "reserved".into(),
1080 queue: rpi_harness::session::types::QueueKind::NextRun,
1081 }),
1082 serde_json::json!({"status":"queued", "entryId":"reserved", "deliverAs":"nextTurn"})
1083 );
1084 }
1085}
1086
1087#[cfg(test)]
1088mod list_commands_tests {
1089 use super::merge_command_list_reserved;
1090
1091 #[test]
1097 fn reserved_names_do_not_remove_the_builtins_themselves() {
1098 let builtin = vec![
1099 ("/help".to_string(), "help".to_string()),
1100 ("/model".to_string(), "model".to_string()),
1101 ];
1102 let out = merge_command_list_reserved(
1103 builtin,
1104 vec![("deploy".into(), "deploy".into())],
1105 vec![],
1106 vec![
1109 "/help".to_string(),
1110 "/model".to_string(),
1111 "/q".to_string(),
1112 "/context".to_string(),
1113 ],
1114 );
1115 assert_eq!(names(&out), vec!["/help", "/model", "/deploy"]);
1116 assert_eq!(out[0]["source"], "builtin");
1117 assert_eq!(out[1]["source"], "builtin");
1118 assert_eq!(out[2]["source"], "extension");
1119 }
1120
1121 #[test]
1125 fn reserved_builtin_aliases_hide_colliding_extension_entries() {
1126 let out = merge_command_list_reserved(
1127 vec![("/help".into(), "help".into())],
1128 vec![
1129 ("q".into(), "quit".into()),
1130 ("context".into(), "context".into()),
1131 ("deploy".into(), "deploy".into()),
1132 ],
1133 vec![],
1134 vec!["/q".into(), "/context".into()],
1135 );
1136 assert_eq!(names(&out), vec!["/help", "/deploy"]);
1138 assert_eq!(out[1]["source"], "extension");
1139 }
1140
1141 fn names(v: &[serde_json::Value]) -> Vec<&str> {
1142 v.iter().map(|c| c["name"].as_str().unwrap()).collect()
1143 }
1144
1145 #[test]
1146 fn builtin_wins_over_a_same_named_extension_command() {
1147 let out = merge_command_list_reserved(
1148 vec![("/model".into(), "built-in".into())],
1149 vec![("/model".into(), "extension".into())],
1150 vec![],
1151 Vec::new(),
1152 );
1153 assert_eq!(names(&out), vec!["/model"]);
1154 assert_eq!(out[0]["source"], "builtin");
1155 assert_eq!(out[0]["description"], "built-in");
1156 }
1157
1158 #[test]
1159 fn extension_and_template_names_get_a_leading_slash() {
1160 let out = merge_command_list_reserved(
1161 vec![],
1162 vec![("deploy".into(), "deploy".into())],
1163 vec![("review".into(), "review".into())],
1164 Vec::new(),
1165 );
1166 assert_eq!(names(&out), vec!["/deploy", "/review"]);
1167 assert_eq!(out[0]["source"], "extension");
1168 assert_eq!(out[1]["source"], "prompt");
1169 }
1170
1171 #[test]
1175 fn prompt_templates_are_listed_with_their_own_source() {
1176 let out = merge_command_list_reserved(
1177 vec![("/help".into(), "help".into())],
1178 vec![],
1179 vec![("review".into(), "review a diff".into())],
1180 Vec::new(),
1181 );
1182 assert_eq!(names(&out), vec!["/help", "/review"]);
1183 assert_eq!(out[1]["source"], "prompt");
1184 assert_eq!(out[1]["description"], "review a diff");
1185 }
1186
1187 #[test]
1190 fn a_builtin_name_is_not_reported_as_a_template() {
1191 let out = merge_command_list_reserved(
1192 vec![("/review".into(), "built-in".into())],
1193 vec![],
1194 vec![("review".into(), "template".into())],
1195 Vec::new(),
1196 );
1197 assert_eq!(names(&out), vec!["/review"]);
1198 assert_eq!(out[0]["source"], "builtin");
1199 }
1200
1201 #[test]
1202 fn no_commands_is_an_empty_list_not_an_error() {
1203 assert!(merge_command_list_reserved(vec![], vec![], vec![], Vec::new()).is_empty());
1204 }
1205}