1use std::collections::{HashMap, VecDeque};
32use std::ffi::c_void;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::{mpsc, Arc, Mutex, OnceLock, Weak};
35
36struct RegistryEntry {
39 bridge: Weak<ActionBridge>,
40 active: Weak<AtomicBool>,
41}
42static BRIDGES: OnceLock<Mutex<HashMap<usize, RegistryEntry>>> = OnceLock::new();
43fn bridges() -> &'static Mutex<HashMap<usize, RegistryEntry>> {
44 BRIDGES.get_or_init(Mutex::default)
45}
46thread_local! {
47 static CURRENT_INVOCATION: std::cell::RefCell<Option<Arc<ActionBridge>>> = const { std::cell::RefCell::new(None) };
48}
49pub(crate) struct InvocationGuard {
50 previous: Option<Arc<ActionBridge>>,
51 _thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
52}
53impl Drop for InvocationGuard {
54 fn drop(&mut self) {
55 CURRENT_INVOCATION.with(|slot| *slot.borrow_mut() = self.previous.take());
56 }
57}
58
59use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
60use tokio::runtime::Handle;
61
62pub const PLUGIN_RESERVED_PREFIX: &str = "pidingo:";
72
73#[derive(Clone, Debug, PartialEq)]
80pub struct UiDialogRequest {
81 pub request_id: String,
82 pub tool_call_id: Option<String>,
83 pub ui: serde_json::Value,
84 pub raw: serde_json::Value,
85}
86
87struct UiDialogEntry {
88 request: UiDialogRequest,
89 answer: Option<serde_json::Value>,
90 cancelled: bool,
91}
92
93struct UiDialogState {
94 attached: bool,
95 external_takeover: bool,
102 pending: VecDeque<String>,
103 active: HashMap<String, UiDialogEntry>,
104 cancelled_tool_calls: std::collections::HashSet<String>,
105}
106
107#[derive(Clone)]
111pub struct UiDialogMailbox {
112 state: Arc<Mutex<UiDialogState>>,
113}
114
115impl Default for UiDialogMailbox {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl UiDialogMailbox {
122 pub fn new() -> Self {
123 Self {
124 state: Arc::new(Mutex::new(UiDialogState {
125 attached: false,
126 external_takeover: false,
127 pending: VecDeque::new(),
128 active: HashMap::new(),
129 cancelled_tool_calls: std::collections::HashSet::new(),
130 })),
131 }
132 }
133
134 pub fn attach(&self) {
136 self.state
137 .lock()
138 .expect("ui dialog mailbox poisoned")
139 .attached = true;
140 }
141
142 pub fn set_external_takeover(&self, takeover: bool) {
153 self.state
154 .lock()
155 .expect("ui dialog mailbox poisoned")
156 .external_takeover = takeover;
157 }
158
159 pub fn external_takeover(&self) -> bool {
161 self.state
162 .lock()
163 .expect("ui dialog mailbox poisoned")
164 .external_takeover
165 }
166
167 pub fn detach(&self) {
170 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
171 state.attached = false;
172 state.external_takeover = false;
176 state.pending.clear();
177 for entry in state.active.values_mut() {
178 entry.cancelled = true;
179 entry.answer = None;
180 }
181 }
182
183 pub fn release_takeover(&self) -> usize {
192 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
193 state.external_takeover = false;
194 let mut requeued: Vec<String> = state
195 .active
196 .iter()
197 .filter(|(_, entry)| !entry.cancelled && entry.answer.is_none())
198 .map(|(request_id, _)| request_id.clone())
199 .filter(|request_id| !state.pending.contains(request_id))
200 .collect();
201 if requeued.is_empty() {
202 return 0;
203 }
204 let count = requeued.len();
205 requeued.sort();
207 for request_id in requeued.into_iter().rev() {
208 state.pending.push_front(request_id);
209 }
210 count
211 }
212
213 pub fn is_attached(&self) -> bool {
214 self.state
215 .lock()
216 .expect("ui dialog mailbox poisoned")
217 .attached
218 }
219
220 pub fn take_pending(&self) -> Option<UiDialogRequest> {
224 self.take_pending_filtered(|_| true)
225 }
226
227 pub fn take_pending_unless_external<F>(&self, keep: F) -> Option<UiDialogRequest>
236 where
237 F: Fn(&UiDialogRequest) -> bool,
238 {
239 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
240 if state.external_takeover {
241 return None;
242 }
243 Self::take_pending_locked(&mut state, keep)
244 }
245
246 pub fn take_pending_filtered<F>(&self, keep: F) -> Option<UiDialogRequest>
261 where
262 F: Fn(&UiDialogRequest) -> bool,
263 {
264 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
265 Self::take_pending_locked(&mut state, keep)
266 }
267
268 fn take_pending_locked<F>(
269 state: &mut std::sync::MutexGuard<'_, UiDialogState>,
270 keep: F,
271 ) -> Option<UiDialogRequest>
272 where
273 F: Fn(&UiDialogRequest) -> bool,
274 {
275 let mut scanned = 0usize;
276 let total = state.pending.len();
277 while scanned < total {
278 scanned += 1;
279 let Some(request_id) = state.pending.pop_front() else {
280 break;
281 };
282 let Some(entry) = state.active.get(&request_id) else {
283 continue;
284 };
285 if entry.cancelled || entry.answer.is_some() {
286 continue;
287 }
288 let request = entry.request.clone();
289 if keep(&request) {
290 return Some(request);
291 }
292 state.pending.push_back(request_id);
294 }
295 None
296 }
297
298 pub fn take_all_unreserved(&self) -> Vec<UiDialogRequest> {
307 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
308 let mut out: Vec<UiDialogRequest> = Vec::new();
309 let drained = std::mem::take(&mut state.pending);
311 for request_id in drained {
312 let Some(entry) = state.active.get(&request_id) else {
313 continue;
314 };
315 if entry.cancelled || entry.answer.is_some() {
316 continue;
317 }
318 if request_id.starts_with(PLUGIN_RESERVED_PREFIX) {
319 state.pending.push_back(request_id);
320 } else {
321 out.push(entry.request.clone());
322 }
323 }
324 out
325 }
326
327 pub fn is_reserved(&self, request_id: &str) -> bool {
329 request_id.starts_with(PLUGIN_RESERVED_PREFIX)
330 }
331
332 pub fn pending_len(&self) -> usize {
333 self.state
334 .lock()
335 .expect("ui dialog mailbox poisoned")
336 .pending
337 .len()
338 }
339
340 pub fn is_pending(&self, request_id: &str) -> bool {
342 self.state
343 .lock()
344 .expect("ui dialog mailbox poisoned")
345 .active
346 .get(request_id)
347 .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
348 }
349
350 pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
353 let mut state = self
354 .state
355 .lock()
356 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
357 let entry = state
358 .active
359 .get_mut(request_id)
360 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
361 if entry.cancelled {
362 return Err(format!("UI request `{request_id}` was cancelled"));
363 }
364 if entry.answer.is_none() {
365 entry.answer = Some(answer);
366 }
367 Ok(())
368 }
369
370 pub fn cancel(&self, request_id: &str) -> Result<(), String> {
373 let mut state = self
374 .state
375 .lock()
376 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
377 let entry = state
378 .active
379 .get_mut(request_id)
380 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
381 entry.cancelled = true;
382 entry.answer = None;
383 state.pending.retain(|id| id != request_id);
384 Ok(())
385 }
386
387 pub fn cancel_all(&self) {
389 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
390 state.pending.clear();
391 for entry in state.active.values_mut() {
392 entry.cancelled = true;
393 entry.answer = None;
394 }
395 }
396
397 pub fn cancel_tool_call(&self, tool_call_id: &str) {
400 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
401 state.cancelled_tool_calls.insert(tool_call_id.to_string());
404 for entry in state.active.values_mut() {
405 if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
406 entry.cancelled = true;
407 entry.answer = None;
408 }
409 }
410 let cancelled: std::collections::HashSet<String> = state
411 .active
412 .iter()
413 .filter(|(_, entry)| entry.cancelled)
414 .map(|(id, _)| id.clone())
415 .collect();
416 state.pending.retain(|id| !cancelled.contains(id));
417 }
418
419 pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
422 let mut state = self
423 .state
424 .lock()
425 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
426 let terminal = match state.active.get(request_id) {
427 Some(entry) if entry.cancelled => Some(serde_json::json!({
428 "status": "cancelled",
429 "requestId": request_id,
430 "toolCallId": entry.request.tool_call_id,
431 })),
432 Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
433 "status": "answered",
434 "requestId": request_id,
435 "toolCallId": entry.request.tool_call_id,
436 "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
437 })),
438 Some(entry) => Some(serde_json::json!({
439 "status": "pending",
440 "requestId": request_id,
441 "toolCallId": entry.request.tool_call_id,
442 })),
443 None => None,
444 };
445 let Some(result) = terminal else {
446 return Err(format!("unknown UI request `{request_id}`"));
447 };
448 if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
449 state.active.remove(request_id);
450 }
451 Ok(result)
452 }
453
454 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
458 let op = args
459 .get("op")
460 .and_then(serde_json::Value::as_str)
461 .unwrap_or("open");
462 let request_id = || {
463 args.get("requestId")
464 .or_else(|| args.get("request_id"))
465 .and_then(serde_json::Value::as_str)
466 .map(str::to_owned)
467 .filter(|id| !id.trim().is_empty())
468 .ok_or_else(|| "missing non-empty `requestId`".to_string())
469 };
470 match op {
471 "open" => {
472 let id = request_id()?;
473 let mut state = self
474 .state
475 .lock()
476 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
477 if !state.attached {
478 return Err("ask_user requires an interactive UI".to_string());
479 }
480 if let Some(entry) = state.active.get(&id) {
481 return Ok(if entry.cancelled {
482 serde_json::json!({"status":"cancelled","requestId":id})
483 } else if let Some(answer) = &entry.answer {
484 serde_json::json!({"status":"answered","requestId":id,"answer":answer})
485 } else {
486 serde_json::json!({"status":"pending","requestId":id})
487 });
488 }
489 let tool_call_id = args
490 .get("toolCallId")
491 .or_else(|| args.get("tool_call_id"))
492 .and_then(serde_json::Value::as_str)
493 .map(str::to_owned);
494 if tool_call_id
495 .as_ref()
496 .is_some_and(|id| state.cancelled_tool_calls.contains(id))
497 {
498 return Ok(serde_json::json!({
499 "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
500 }));
501 }
502 let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
503 rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
504 state.active.insert(
505 id.clone(),
506 UiDialogEntry {
507 request: UiDialogRequest {
508 request_id: id.clone(),
509 tool_call_id: tool_call_id.clone(),
510 ui,
511 raw: args,
512 },
513 answer: None,
514 cancelled: false,
515 },
516 );
517 state.pending.push_back(id.clone());
518 Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
519 }
520 "poll" => self.poll(&request_id()?),
521 "cancel" => {
522 let id = request_id()?;
523 self.cancel(&id)?;
524 Ok(serde_json::json!({"status":"cancelled","requestId":id}))
525 }
526 "response" | "respond" => {
527 let id = request_id()?;
528 let answer = args
529 .get("answer")
530 .or_else(|| args.get("value"))
531 .cloned()
532 .unwrap_or(serde_json::Value::Null);
533 self.respond(&id, answer)?;
534 Ok(serde_json::json!({"status":"answered","requestId":id}))
535 }
536 other => Err(format!("unknown UI dialog operation `{other}`")),
537 }
538 }
539}
540
541pub type SideQuestionSink =
543 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>;
544
545#[async_trait::async_trait]
560pub trait RuntimeActionHost: Send + Sync {
561 async fn tool_program(
563 &self,
564 capability: Arc<rpi_agent::tool_program::ToolProgram>,
565 args: serde_json::Value,
566 ) -> Result<serde_json::Value, String> {
567 capability.handle(args).await
568 }
569 fn cancel_side_questions(&self, _owner: u64) {}
571 fn side_question_revision(&self) -> u64 {
573 0
574 }
575 fn side_question_views(&self) -> Vec<serde_json::Value> {
576 Vec::new()
577 }
578 async fn side_question_ui(
579 &self,
580 _owner: u64,
581 _thread_id: String,
582 _args: serde_json::Value,
583 ) -> Result<serde_json::Value, String> {
584 Err("SideQuestion UI is not supported by this host".into())
585 }
586 async fn side_question(
588 &self,
589 _owner: u64,
590 _args: serde_json::Value,
591 _sink: SideQuestionSink,
592 ) -> Result<serde_json::Value, String> {
593 Err("SideQuestion is not supported by this host; upgrade rpi".into())
594 }
595
596 fn replace_model_catalog(&self, _models: Vec<rpi_ai::Model>) {}
598 fn cancel_child_agents(&self, _owner: u64) {}
600 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
605 async fn send_user_message(&self, args: serde_json::Value)
607 -> Result<serde_json::Value, String>;
608 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
618 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
620 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
622 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
624 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
626 async fn get_thinking_level(
628 &self,
629 args: serde_json::Value,
630 ) -> Result<serde_json::Value, String>;
631 async fn set_thinking_level(
633 &self,
634 args: serde_json::Value,
635 ) -> Result<serde_json::Value, String>;
636 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
638 async fn get_system_prompt(&self, args: serde_json::Value)
640 -> Result<serde_json::Value, String>;
641 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
643 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
645 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
647 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
649 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
653
654 async fn reload_models(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
664 Err("reload_models is not supported by this host".to_string())
665 }
666
667 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
670 Err("CLI flag lookup is not configured".to_string())
671 }
672
673 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
677 Err("ask_user requires an interactive UI".to_string())
678 }
679
680 async fn list_commands(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
691 Ok(serde_json::json!({ "commands": [] }))
692 }
693
694 fn publish_registry(&self, _snapshot: std::sync::Arc<crate::registry::RegistrySnapshot>) {}
705
706 fn publish_registry_opt(
712 &self,
713 snapshot: Option<std::sync::Arc<crate::registry::RegistrySnapshot>>,
714 ) {
715 if let Some(snapshot) = snapshot {
716 self.publish_registry(snapshot);
717 }
718 }
719
720 async fn abort(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
728 Ok(serde_json::json!({
729 "aborted": false,
730 "steer": [],
731 "followUp": [],
732 }))
733 }
734
735 fn stop_round(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
745 Ok(serde_json::json!({ "requested": true }))
746 }
747
748 async fn get_execution_context(
750 &self,
751 _args: serde_json::Value,
752 ) -> Result<serde_json::Value, String> {
753 Err("execution context is not supported by this host".into())
754 }
755
756 async fn child_agent(
758 &self,
759 _owner: u64,
760 _args: serde_json::Value,
761 ) -> Result<serde_json::Value, String> {
762 Err("ChildAgent is not supported by this host".into())
763 }
764
765 async fn get_session_branch(
775 &self,
776 _args: serde_json::Value,
777 ) -> Result<serde_json::Value, String> {
778 Err("session branch lookup is not configured".to_string())
779 }
780}
781
782pub struct ActionBridge {
809 invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
811 execution_context: Option<serde_json::Value>,
812 tool_program: Option<Arc<rpi_agent::tool_program::ToolProgram>>,
813 stdio: crate::StdioLifecycle,
814 plugin_identity: Option<(u64, String)>,
815 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
816 pub(crate) runtime: Handle,
818 pub(crate) host: Arc<dyn RuntimeActionHost>,
820 pub(crate) reload:
822 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
823 ui_dialog: UiDialogMailbox,
826 ext_status: crate::status::ExtensionStatusMailbox,
830 editor_text: crate::editor_text::EditorTextMailbox,
834 active: Arc<AtomicBool>,
837}
838
839impl ActionBridge {
840 pub fn user_data(self: &Arc<Self>) -> *mut c_void {
842 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
843 let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
844 if let Some((&id, _)) = registry
845 .iter()
846 .find(|(_, entry)| entry.bridge.as_ptr() == Arc::as_ptr(self))
847 {
848 return id as *mut c_void;
849 }
850 let mut id = NEXT.load(Ordering::Relaxed);
851 loop {
852 let next = id.checked_add(1).expect("runtime handle space exhausted");
853 match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
854 Ok(_) => break,
855 Err(current) => id = current,
856 }
857 }
858 registry.insert(
859 id,
860 RegistryEntry {
861 bridge: Arc::downgrade(self),
862 active: Arc::downgrade(&self.active),
863 },
864 );
865 id as *mut c_void
866 }
867 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
868 bridges()
869 .lock()
870 .unwrap_or_else(|e| e.into_inner())
871 .get(&id)
872 .and_then(|entry| entry.bridge.upgrade())
873 }
874 pub(crate) fn for_invocation(
875 &self,
876 child: bool,
877 signal: tokio_util::sync::CancellationToken,
878 ) -> Arc<Self> {
879 let tool_program = rpi_agent::tool_program::current();
880 if let Some(program) = &tool_program {
881 program.bind_signal(signal.clone());
882 }
883 Arc::new(Self {
884 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
885 execution_context: rpi_agent::tool_execution_scope::execution_context(),
886 tool_program,
887 stdio: self.stdio.clone(),
888 plugin_identity: self.plugin_identity.clone(),
889 component_owners: self.component_owners.clone(),
890 runtime: self.runtime.clone(),
891 host: self.host.clone(),
892 reload: self.reload.clone(),
893 ui_dialog: self.ui_dialog.clone(),
894 ext_status: self.ext_status.clone(),
895 editor_text: self.editor_text.clone(),
896 active: self.active.clone(),
897 })
898 }
899 pub(crate) fn resolve_result_reference(
902 &self,
903 id: &str,
904 ) -> Result<(rpi_agent::AgentToolResult, bool), String> {
905 if !self.is_active()
906 || !self
907 .invocation
908 .as_ref()
909 .is_some_and(|(_, _, signal)| !signal.is_cancelled())
910 {
911 return Err("result reference requires a live invocation bridge".into());
912 }
913 self.tool_program
914 .as_ref()
915 .ok_or("result reference requires an effective ToolProgram")?
916 .resolve_result_reference(id)
917 }
918 pub(crate) fn resolve_content_parts(
920 &self,
921 parts: Vec<rpi_agent::tool_program::ToolProgramContentPart>,
922 ) -> Result<Vec<rpi_agent::TextContentOrImage>, String> {
923 if !self.is_active()
924 || !self
925 .invocation
926 .as_ref()
927 .is_some_and(|(_, _, signal)| !signal.is_cancelled())
928 {
929 return Err("content parts require a live invocation bridge".into());
930 }
931 self.tool_program
932 .as_ref()
933 .ok_or("content parts require an effective ToolProgram")?
934 .resolve_content_parts(parts)
935 }
936 pub(crate) fn invocation_key(&self) -> Option<&str> {
937 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
938 }
939 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
940 InvocationGuard {
941 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
942 _thread_bound: std::marker::PhantomData,
943 }
944 }
945 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
948 Arc::new(Self {
949 invocation: None,
950 tool_program: None,
951 execution_context: None,
952 stdio: crate::StdioLifecycle::default(),
953 plugin_identity: None,
954 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
955 runtime,
956 host,
957 reload: None,
958 ui_dialog: UiDialogMailbox::new(),
959 ext_status: crate::status::ExtensionStatusMailbox::new(),
960 editor_text: crate::editor_text::EditorTextMailbox::new(),
961 active: Arc::new(AtomicBool::new(true)),
962 })
963 }
964
965 pub fn with_reload(
968 runtime: Handle,
969 host: Arc<dyn RuntimeActionHost>,
970 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
971 ) -> Arc<Self> {
972 Self::with_reload_and_ui(
973 runtime,
974 host,
975 reload,
976 UiDialogMailbox::new(),
977 crate::status::ExtensionStatusMailbox::new(),
978 crate::editor_text::EditorTextMailbox::new(),
979 )
980 }
981
982 pub fn with_ui_dialog(
984 runtime: Handle,
985 host: Arc<dyn RuntimeActionHost>,
986 ui_dialog: UiDialogMailbox,
987 ) -> Arc<Self> {
988 Arc::new(Self {
989 invocation: None,
990 tool_program: None,
991 execution_context: None,
992 stdio: crate::StdioLifecycle::default(),
993 plugin_identity: None,
994 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
995 runtime,
996 host,
997 reload: None,
998 ui_dialog,
999 ext_status: crate::status::ExtensionStatusMailbox::new(),
1000 editor_text: crate::editor_text::EditorTextMailbox::new(),
1001 active: Arc::new(AtomicBool::new(true)),
1002 })
1003 }
1004
1005 pub fn with_reload_and_ui(
1007 runtime: Handle,
1008 host: Arc<dyn RuntimeActionHost>,
1009 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
1010 ui_dialog: UiDialogMailbox,
1011 ext_status: crate::status::ExtensionStatusMailbox,
1012 editor_text: crate::editor_text::EditorTextMailbox,
1013 ) -> Arc<Self> {
1014 Arc::new(Self {
1015 invocation: None,
1016 tool_program: None,
1017 execution_context: None,
1018 stdio: crate::StdioLifecycle::default(),
1019 plugin_identity: None,
1020 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
1021 runtime,
1022 host,
1023 reload: Some(reload),
1024 ui_dialog,
1025 ext_status,
1026 editor_text,
1027 active: Arc::new(AtomicBool::new(true)),
1028 })
1029 }
1030
1031 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
1032 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1033 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1034 self.component_owners
1035 .lock()
1036 .unwrap_or_else(|e| e.into_inner())
1037 .push(owner);
1038 Arc::new(Self {
1039 invocation: None,
1040 tool_program: None,
1041 execution_context: None,
1042 component_owners: self.component_owners.clone(),
1043 stdio: self.stdio.clone(),
1044 plugin_identity: Some((owner, name.to_owned())),
1045 runtime: self.runtime.clone(),
1046 host: self.host.clone(),
1047 reload: self.reload.clone(),
1048 ui_dialog: self.ui_dialog.clone(),
1049 ext_status: self.ext_status.clone(),
1050 editor_text: self.editor_text.clone(),
1051 active: self.active.clone(),
1052 })
1053 }
1054
1055 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
1056 self.stdio.clone()
1057 }
1058
1059 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
1060 let (id, name) = self
1061 .plugin_identity
1062 .as_ref()
1063 .ok_or("missing plugin identity")?;
1064 self.stdio.claim(*id, name)
1065 }
1066
1067 pub(crate) fn release_stdio_claim(&self) {
1068 if let Some((id, _)) = &self.plugin_identity {
1069 self.stdio.release_failed_registration(*id);
1070 self.ext_status.remove_owner(*id);
1071 }
1072 }
1073
1074 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
1076 self.ui_dialog.clone()
1077 }
1078
1079 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
1081 self.ext_status.clone()
1082 }
1083
1084 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
1086 self.editor_text.clone()
1087 }
1088
1089 pub fn invalidate(&self) {
1094 let owners = {
1095 let owners = self
1096 .component_owners
1097 .lock()
1098 .unwrap_or_else(|e| e.into_inner());
1099 self.active.store(false, Ordering::SeqCst);
1100 for owner in owners.iter() {
1101 self.ext_status.remove_owner(*owner);
1102 }
1103 owners.clone()
1104 };
1105 let live = self.bridges_for_owner();
1107 for bridge in live {
1108 if let Some(program) = &bridge.tool_program {
1109 program.cancel_all();
1110 }
1111 }
1112 for owner in owners {
1115 self.host.cancel_child_agents(owner);
1116 self.host.cancel_side_questions(owner);
1117 }
1118 }
1119
1120 fn bridges_for_owner(&self) -> Vec<Arc<Self>> {
1121 let active = Arc::downgrade(&self.active);
1122 bridges()
1128 .lock()
1129 .unwrap_or_else(|e| e.into_inner())
1130 .values()
1131 .filter(|entry| entry.active.ptr_eq(&active))
1132 .filter_map(|entry| entry.bridge.upgrade())
1133 .collect()
1134 }
1135
1136 pub fn is_active(&self) -> bool {
1138 self.active.load(Ordering::SeqCst)
1139 }
1140
1141 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
1148 Arc::clone(&self.host)
1149 }
1150}
1151
1152#[derive(Clone)]
1165pub struct ReloadMailbox {
1166 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
1167}
1168
1169impl Default for ReloadMailbox {
1170 fn default() -> Self {
1171 Self {
1172 tx: Arc::new(std::sync::Mutex::new(None)),
1173 }
1174 }
1175}
1176
1177impl ReloadMailbox {
1178 pub fn new() -> Self {
1180 Self::default()
1181 }
1182
1183 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
1187 *self.tx.lock().unwrap() = Some(tx);
1188 }
1189
1190 pub fn signal(&self) -> Result<(), ()> {
1195 let g = self.tx.lock().unwrap();
1196 match &*g {
1197 Some(tx) => {
1198 let _ = tx.send(());
1199 Ok(())
1200 }
1201 None => Err(()),
1202 }
1203 }
1204
1205 pub fn clear(&self) {
1207 *self.tx.lock().unwrap() = None;
1208 }
1209}
1210
1211pub struct ReloadModelsMailbox {
1222 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<ReloadModelsRequest>>>>,
1223}
1224
1225impl Default for ReloadModelsMailbox {
1226 fn default() -> Self {
1227 Self {
1228 tx: Arc::new(std::sync::Mutex::new(None)),
1229 }
1230 }
1231}
1232
1233pub struct ReloadModelsRequest {
1235 pub reply: tokio::sync::oneshot::Sender<Result<usize, String>>,
1237}
1238
1239impl Clone for ReloadModelsMailbox {
1240 fn clone(&self) -> Self {
1241 Self {
1242 tx: Arc::clone(&self.tx),
1243 }
1244 }
1245}
1246
1247impl ReloadModelsMailbox {
1248 pub fn new() -> Self {
1249 Self::default()
1250 }
1251
1252 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<ReloadModelsRequest>) {
1254 *self.tx.lock().unwrap() = Some(tx);
1255 }
1256
1257 pub async fn request(&self) -> Result<usize, String> {
1262 let (reply, wait) = tokio::sync::oneshot::channel();
1263 let tx = self.tx.lock().unwrap().clone();
1264 let Some(tx) = tx else {
1265 return Err("reload_models is not available (no TUI attached)".to_string());
1266 };
1267 tx.send(ReloadModelsRequest { reply })
1268 .map_err(|_| "reload_models request dropped (TUI gone)".to_string())?;
1269 wait.await
1270 .map_err(|_| "reload_models request dropped (TUI gone)".to_string())?
1271 }
1272
1273 pub fn clear(&self) {
1275 *self.tx.lock().unwrap() = None;
1276 }
1277}
1278
1279pub fn reload_callback_from_mailbox(
1286 mailbox: ReloadMailbox,
1287) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
1288 Arc::new(move || {
1289 let m = mailbox.clone();
1290 Box::pin(async move {
1291 let _ = m.signal();
1292 })
1293 })
1294}
1295
1296fn dispatch_relay_ui_dialog(
1311 ui_dialog: &UiDialogMailbox,
1312 args: serde_json::Value,
1313) -> Result<serde_json::Value, String> {
1314 let op = args
1315 .get("op")
1316 .and_then(serde_json::Value::as_str)
1317 .unwrap_or("take");
1318 match op {
1319 "take" => {
1320 ui_dialog.set_external_takeover(true);
1324 let requests: Vec<serde_json::Value> = ui_dialog
1325 .take_all_unreserved()
1326 .into_iter()
1327 .map(|request| {
1328 serde_json::json!({
1329 "requestId": request.request_id,
1330 "toolCallId": request.tool_call_id,
1331 "ui": request.ui,
1332 })
1333 })
1334 .collect();
1335 Ok(serde_json::json!({ "requests": requests }))
1336 }
1337 "release" => {
1340 let requeued = ui_dialog.release_takeover();
1341 Ok(serde_json::json!({ "requeued": requeued }))
1342 }
1343 "respond" | "cancel" => ui_dialog.handle(args),
1345 other => Err(format!("RelayUiDialog does not support op `{other}`")),
1346 }
1347}
1348
1349async fn dispatch(
1350 host: &Arc<dyn RuntimeActionHost>,
1351 ui_dialog: &UiDialogMailbox,
1352 ext_status: &crate::status::ExtensionStatusMailbox,
1353 editor_text: &crate::editor_text::EditorTextMailbox,
1354 action: RuntimeActionId,
1355 args: serde_json::Value,
1356) -> Result<serde_json::Value, String> {
1357 match action {
1358 RuntimeActionId::SendMessage => host.send_message(args).await,
1359 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
1360 RuntimeActionId::AppendEntry => host.append_entry(args).await,
1361 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
1362 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
1363 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
1364 RuntimeActionId::SetModel => host.set_model(args).await,
1365 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
1366 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
1367 RuntimeActionId::Compact => host.compact(args).await,
1368 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
1369 RuntimeActionId::NewSession => host.new_session(args).await,
1370 RuntimeActionId::Fork => host.fork(args).await,
1371 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
1372 RuntimeActionId::SwitchSession => host.switch_session(args).await,
1373 RuntimeActionId::Reload => host.reload(args).await,
1374 RuntimeActionId::ReloadModels => host.reload_models(args).await,
1375 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
1376 RuntimeActionId::UiDialog => ui_dialog.handle(args),
1377 RuntimeActionId::SetStatus => ext_status.handle(args),
1380 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
1381 RuntimeActionId::SetEditorText => editor_text.handle(args),
1384 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
1385 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
1386 RuntimeActionId::ToolProgram => Err("ToolProgram requires a live invocation bridge".into()),
1387 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
1388 RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
1389 RuntimeActionId::ListCommands => host.list_commands(args).await,
1390 RuntimeActionId::Abort => host.abort(args).await,
1391 RuntimeActionId::RelayUiDialog => dispatch_relay_ui_dialog(ui_dialog, args),
1395 RuntimeActionId::StopRound => host.stop_round(args),
1396 RuntimeActionId::StdioLifecycle => {
1397 Err("stdio lifecycle requires a plugin context".to_owned())
1398 }
1399 }
1400}
1401
1402pub extern "C" fn trampoline_runtime_action(
1426 action_id: u32,
1427 args_json: StbStringRef,
1428 out: *mut StbString,
1429 user_data: *mut c_void,
1430) -> i32 {
1431 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1432 run_action(
1433 action_id,
1434 rpi_plugin_sdk::RuntimeActionId::MAX,
1436 args_json,
1437 out,
1438 user_data,
1439 )
1440 }));
1441 match outcome {
1442 Ok(rc) => rc,
1443 Err(_) => {
1444 tracing::error!("runtime_action trampoline panicked — reporting host error");
1448 if !out.is_null() {
1449 unsafe {
1450 *out = StbString::from_string(
1451 r#"{"error":"runtime_action panicked"}"#.to_string(),
1452 );
1453 }
1454 }
1455 1
1456 }
1457 }
1458}
1459
1460fn run_action(
1462 action_id: u32,
1463 max_action_id: u32,
1464 args_json: StbStringRef,
1465 out: *mut StbString,
1466 user_data: *mut c_void,
1467) -> i32 {
1468 let action = match RuntimeActionId::try_from(action_id) {
1472 Ok(action) if action_id <= max_action_id => action,
1473 Ok(_) | Err(_) => {
1474 if !out.is_null() {
1475 let json = serde_json::json!({
1476 "error": format!("unknown runtime action id {action_id}")
1477 })
1478 .to_string();
1479 unsafe {
1483 *out = StbString::from_string(json);
1484 }
1485 }
1486 return 2;
1487 }
1488 };
1489
1490 if user_data.is_null() {
1491 return -1;
1492 }
1493 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1496 let invocation_thread = invocation.is_some();
1497 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1498 let Some(bridge) = bridge else {
1499 if !out.is_null() {
1500 unsafe {
1502 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1503 }
1504 }
1505 return 1;
1506 };
1507
1508 if !bridge.is_active() {
1516 if out.is_null() {
1517 return 1;
1518 }
1519 let json = serde_json::json!({
1520 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1521 })
1522 .to_string();
1523 unsafe {
1524 *out = StbString::from_string(json);
1525 }
1526 return 1;
1527 }
1528
1529 let args_str = unsafe { args_json.as_str() };
1532 let mut args: serde_json::Value = if args_str.is_empty() {
1533 serde_json::Value::Object(serde_json::Map::new())
1534 } else {
1535 serde_json::from_str(args_str)
1536 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1537 };
1538
1539 let runtime_key = args
1540 .as_object_mut()
1541 .and_then(|args| args.remove("__rpiRuntimeKey"));
1542
1543 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1548 let host = Arc::clone(&bridge.host);
1552 let ui_dialog = bridge.ui_dialog.clone();
1553 let ext_status = bridge.ext_status.clone();
1554 let editor_text = bridge.editor_text.clone();
1555 let reload_cb = bridge.reload.clone();
1556 let execution_context = bridge.execution_context.clone();
1557 if action == RuntimeActionId::StdioLifecycle {
1558 let result = bridge.stdio.handle(
1559 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1560 args,
1561 );
1562 let _ = tx.send(result);
1563 } else if action == RuntimeActionId::UiComponent {
1564 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1565 let _owners = bridge
1566 .component_owners
1567 .lock()
1568 .unwrap_or_else(|e| e.into_inner());
1569 let result = if bridge.is_active() {
1570 ext_status.component(owner, args)
1571 } else {
1572 Err("stale plugin bridge".into())
1573 };
1574 let _ = tx.send(result);
1575 } else if action == RuntimeActionId::SideQuestion {
1576 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1577 if owner == 0 || bridge.invocation.is_some() {
1578 let _ = tx.send(Err(
1579 "SideQuestion requires a plugin command, not a tool invocation".into(),
1580 ));
1581 } else {
1582 let weak = Arc::downgrade(&bridge);
1585 let sink: SideQuestionSink = Arc::new(move |args| {
1586 let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1587 let _owners = bridge
1588 .component_owners
1589 .lock()
1590 .unwrap_or_else(|e| e.into_inner());
1591 if !bridge.is_active() {
1592 return Err("stale side question bridge".into());
1593 }
1594 if args["op"] == "check" {
1595 return Ok(serde_json::json!({"ok":true}));
1596 }
1597 bridge.ext_status.component(owner, args)
1598 });
1599 bridge.runtime.spawn(async move {
1600 let _ = tx.send(host.side_question(owner, args, sink).await);
1601 });
1602 }
1603 } else if action == RuntimeActionId::GetActiveTools && bridge.tool_program.is_some() {
1604 let tools = bridge
1605 .tool_program
1606 .as_ref()
1607 .expect("checked")
1608 .active_tool_names();
1609 let _ = tx.send(Ok(serde_json::json!({"tools":tools})));
1610 } else if action == RuntimeActionId::SetActiveTools && bridge.execution_context.is_some() {
1611 let _ = tx.send(Err(
1612 "cannot replace active tools from a narrowed execution".into()
1613 ));
1614 } else if action == RuntimeActionId::ToolProgram {
1615 let cleanup = matches!(
1616 args.get("op").and_then(serde_json::Value::as_str),
1617 Some("poll" | "cancel" | "dispose" | "release_result")
1618 );
1619 let authenticated = bridge.invocation.as_ref().is_some_and(|(_, key, signal)| {
1620 (cleanup || !signal.is_cancelled())
1621 && (invocation_thread
1622 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1623 == Some(key.as_str()))
1624 });
1625 if let Some(capability) = bridge.tool_program.clone().filter(|_| authenticated) {
1626 let signal = bridge.invocation.as_ref().expect("authenticated").2.clone();
1627 bridge.runtime.spawn(async move {
1628 if signal.is_cancelled() {
1629 capability.cancel_all();
1630 if !cleanup {
1631 let _ = tx.send(Err("parent invocation is no longer active".into()));
1632 return;
1633 }
1634 }
1635 let _ = tx.send(host.tool_program(capability, args).await);
1636 });
1637 } else {
1638 let _ = tx.send(Err(
1639 "ToolProgram requires an authenticated executing invocation".into(),
1640 ));
1641 }
1642 } else if action == RuntimeActionId::ChildAgent {
1643 let cleanup = matches!(
1648 args.get("op").and_then(serde_json::Value::as_str),
1649 Some("poll" | "cancel" | "dispose")
1650 );
1651 let allowed = bridge
1652 .invocation
1653 .as_ref()
1654 .is_some_and(|(child, key, signal)| {
1655 !child
1656 && (cleanup || !signal.is_cancelled())
1657 && (invocation_thread
1658 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1659 == Some(key.as_str()))
1660 });
1661 if !allowed {
1662 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1663 } else {
1664 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1665 let signal = bridge
1666 .invocation
1667 .as_ref()
1668 .expect("authorized invocation")
1669 .2
1670 .clone();
1671 bridge.runtime.spawn(async move {
1672 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1673 signal,
1674 host.child_agent(owner, args),
1675 )
1676 .await;
1677 let _ = tx.send(result);
1678 });
1679 }
1680 } else if action == RuntimeActionId::SetStatus {
1681 std::thread::spawn(move || {
1686 let _ = tx.send(ext_status.handle(args));
1687 });
1688 } else if action == RuntimeActionId::SetEditorText {
1689 std::thread::spawn(move || {
1692 let _ = tx.send(editor_text.handle(args));
1693 });
1694 } else if action == RuntimeActionId::RelayUiDialog {
1695 std::thread::spawn(move || {
1701 let _ = tx.send(dispatch_relay_ui_dialog(&ui_dialog, args));
1702 });
1703 } else if action == RuntimeActionId::UiDialog {
1704 std::thread::spawn(move || {
1710 let _ = tx.send(ui_dialog.handle(args));
1711 });
1712 } else {
1713 bridge.runtime.spawn(async move {
1714 let mut r = if action == RuntimeActionId::Reload {
1715 if let Some(cb) = reload_cb {
1716 cb().await;
1717 Ok(serde_json::Value::Null)
1718 } else {
1719 host.reload(args).await
1720 }
1721 } else {
1722 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1723 };
1724 if action == RuntimeActionId::GetExecutionContext {
1725 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1726 (&mut r, execution_context)
1727 {
1728 if let Some(object) = context.as_object_mut() {
1729 object.extend(fields);
1730 }
1731 }
1732 }
1733 let _ = tx.send(r);
1736 });
1737 }
1738
1739 let received = if Handle::try_current()
1745 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1746 {
1747 tokio::task::block_in_place(|| rx.recv())
1748 } else {
1749 rx.recv()
1750 };
1751 let result = match received {
1752 Ok(r) => r,
1753 Err(_) => {
1754 return -2;
1759 }
1760 };
1761
1762 if out.is_null() {
1765 return match result {
1767 Ok(_) => 0,
1768 Err(_) => 1,
1769 };
1770 }
1771
1772 let (rc, payload) = match result {
1773 Ok(value) => {
1774 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1775 (0, json)
1776 }
1777 Err(msg) => {
1778 let json = serde_json::json!({ "error": msg }).to_string();
1779 (1, json)
1780 }
1781 };
1782 unsafe {
1788 *out = StbString::from_string(payload);
1789 }
1790 rc
1791}
1792
1793#[cfg(test)]
1802mod tests {
1803 use super::*;
1804 use std::sync::Mutex;
1805
1806 #[tokio::test(flavor = "current_thread")]
1807 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1808 let master = ActionBridge::new(
1809 Handle::current(),
1810 Arc::new(MockHost {
1811 prompt: String::new(),
1812 saw: Mutex::new(Vec::new()),
1813 }),
1814 );
1815 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1816 let owner = crate::HostApi::with_action_bridge(
1817 "owner",
1818 crate::ExtensionRegistry::new(),
1819 diag.clone(),
1820 master.clone(),
1821 );
1822 let other = crate::HostApi::with_action_bridge(
1823 "other",
1824 crate::ExtensionRegistry::new(),
1825 diag,
1826 master.clone(),
1827 );
1828 let vtable = owner.build_vtable();
1829 let claim = r#"{"ownsStdio":true}"#;
1830 {
1831 let _registration = crate::enter_current_api(&owner);
1832 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1833 }
1834 let other_vtable = other.build_vtable();
1835 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1836 let mut out = StbString::empty();
1837 assert_eq!(
1839 (other_vtable.runtime_action)(
1840 RuntimeActionId::StdioLifecycle as u32,
1841 finish,
1842 &mut out,
1843 other_vtable.user_data
1844 ),
1845 1
1846 );
1847 unsafe { (other_vtable.free_string)(out) };
1849 assert_eq!(
1850 (vtable.runtime_action)(
1851 RuntimeActionId::StdioLifecycle as u32,
1852 finish,
1853 &mut out,
1854 vtable.user_data
1855 ),
1856 0
1857 );
1858 unsafe { (vtable.free_string)(out) };
1860 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1861 {
1862 let _registration = crate::enter_current_api(&other);
1863 assert_ne!(
1864 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1865 0
1866 );
1867 }
1868 assert!(master
1869 .stdio_lifecycle()
1870 .validate()
1871 .unwrap_err()
1872 .contains("owner"));
1873 }
1874
1875 struct MockHost {
1880 prompt: String,
1881 saw: Mutex<Vec<RuntimeActionId>>,
1882 }
1883
1884 #[test]
1885 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1886 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1887 if std::env::var_os(CHILD).is_some() {
1888 let runtime = tokio::runtime::Builder::new_multi_thread()
1889 .worker_threads(1)
1890 .enable_all()
1891 .build()
1892 .unwrap();
1893 runtime.block_on(async {
1894 tokio::spawn(async {
1897 let host = Arc::new(MockHost {
1898 prompt: "worker callback".into(),
1899 saw: Mutex::new(Vec::new()),
1900 });
1901 let bridge = ActionBridge::new(Handle::current(), host);
1902 let mut out = StbString::empty();
1903 assert_eq!(
1904 trampoline_runtime_action(
1905 RuntimeActionId::GetSystemPrompt.into(),
1906 StbStringRef::from_str("{}"),
1907 &mut out,
1908 bridge.user_data(),
1909 ),
1910 0
1911 );
1912 let text = unsafe {
1914 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1915 out,
1916 Some(crate::host_free_string),
1917 )
1918 }
1919 .to_string_lossy();
1920 assert_eq!(
1921 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1922 "worker callback"
1923 );
1924 })
1925 .await
1926 .unwrap();
1927 });
1928 return;
1929 }
1930 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1933 .args([
1934 "--exact",
1935 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1936 "--nocapture",
1937 ])
1938 .env(CHILD, "1")
1939 .stdout(std::process::Stdio::piped())
1940 .stderr(std::process::Stdio::piped())
1941 .spawn()
1942 .unwrap();
1943 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1944 while child.try_wait().unwrap().is_none() {
1945 if std::time::Instant::now() >= deadline {
1946 let _ = child.kill();
1947 let _ = child.wait();
1948 panic!("runtime action blocked the worker needed by its own dispatch");
1949 }
1950 std::thread::sleep(std::time::Duration::from_millis(10));
1951 }
1952 let output = child.wait_with_output().unwrap();
1953 assert!(
1954 output.status.success(),
1955 "{}\n{}",
1956 String::from_utf8_lossy(&output.stdout),
1957 String::from_utf8_lossy(&output.stderr)
1958 );
1959 }
1960
1961 #[async_trait::async_trait]
1962 impl RuntimeActionHost for MockHost {
1963 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1964 unreachable!("not under test")
1965 }
1966 async fn send_user_message(
1967 &self,
1968 _: serde_json::Value,
1969 ) -> Result<serde_json::Value, String> {
1970 unreachable!("not under test")
1971 }
1972 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1973 unreachable!("not under test")
1974 }
1975 async fn set_session_name(
1976 &self,
1977 _: serde_json::Value,
1978 ) -> Result<serde_json::Value, String> {
1979 unreachable!("not under test")
1980 }
1981 async fn get_active_tools(
1982 &self,
1983 _: serde_json::Value,
1984 ) -> Result<serde_json::Value, String> {
1985 unreachable!("not under test")
1986 }
1987 async fn set_active_tools(
1988 &self,
1989 _: serde_json::Value,
1990 ) -> Result<serde_json::Value, String> {
1991 unreachable!("not under test")
1992 }
1993 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1994 unreachable!("not under test")
1995 }
1996 async fn get_thinking_level(
1997 &self,
1998 _: serde_json::Value,
1999 ) -> Result<serde_json::Value, String> {
2000 unreachable!("not under test")
2001 }
2002 async fn set_thinking_level(
2003 &self,
2004 _: serde_json::Value,
2005 ) -> Result<serde_json::Value, String> {
2006 unreachable!("not under test")
2007 }
2008 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2009 unreachable!("not under test")
2010 }
2011 async fn get_system_prompt(
2012 &self,
2013 _: serde_json::Value,
2014 ) -> Result<serde_json::Value, String> {
2015 self.saw
2016 .lock()
2017 .unwrap()
2018 .push(RuntimeActionId::GetSystemPrompt);
2019 Ok(serde_json::json!({ "prompt": self.prompt }))
2020 }
2021 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2022 unreachable!("not under test")
2023 }
2024 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2025 unreachable!("not under test")
2026 }
2027 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2028 unreachable!("not under test")
2029 }
2030 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2031 unreachable!("not under test")
2032 }
2033 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2034 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
2035 Ok(serde_json::Value::Null)
2036 }
2037 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
2038 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
2039 Ok(serde_json::json!({ "value": true }))
2040 }
2041 }
2042
2043 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2047 async fn trampoline_round_trips_get_system_prompt() {
2048 let host = Arc::new(MockHost {
2049 prompt: "hello from host".to_string(),
2050 saw: Mutex::new(Vec::new()),
2051 });
2052 let host_for_assert = Arc::clone(&host);
2053 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2054 let runtime = tokio::runtime::Handle::current();
2055 let bridge = ActionBridge::new(runtime, host_dyn);
2056 let user_data = bridge.user_data();
2057
2058 let args_str = "{}";
2060 let args_ref = StbStringRef::from_str(args_str);
2061
2062 let mut out = StbString::empty();
2063 let rc = trampoline_runtime_action(
2064 RuntimeActionId::GetSystemPrompt.into(),
2065 args_ref,
2066 &mut out as *mut StbString,
2067 user_data,
2068 );
2069 assert_eq!(rc, 0, "success return code");
2070
2071 let json_text = unsafe {
2074 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2075 }
2076 .to_string_lossy();
2077 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
2078 assert_eq!(parsed["prompt"], "hello from host");
2079
2080 let saw = host_for_assert.saw.lock().unwrap().clone();
2083 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
2084 }
2085
2086 #[tokio::test]
2092 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
2093 let host = Arc::new(MockHost {
2094 prompt: String::new(),
2095 saw: Mutex::new(Vec::new()),
2096 });
2097 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2098 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2099 let mailbox = bridge.editor_text_mailbox();
2100 let user_data = bridge.user_data();
2101
2102 let args_ref =
2103 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
2104 let mut out = StbString::empty();
2105 let rc = trampoline_runtime_action(
2106 RuntimeActionId::SetEditorText.into(),
2107 args_ref,
2108 &mut out as *mut StbString,
2109 user_data,
2110 );
2111 assert_eq!(rc, 0, "success return code");
2112
2113 let parsed: serde_json::Value = serde_json::from_str(
2115 &unsafe {
2116 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2117 }
2118 .to_string_lossy(),
2119 )
2120 .expect("valid json");
2121 assert_eq!(parsed["ok"], serde_json::json!(true));
2122 assert_eq!(parsed["chars"], serde_json::json!(10));
2123
2124 let edit = mailbox.take_pending().expect("queued edit");
2126 assert_eq!(edit.text, "from voice");
2127 assert_eq!(edit.mode, crate::EditorTextMode::Append);
2128 assert_eq!(edit.auto_send_ms, Some(2000));
2129 assert!(mailbox.take_pending().is_none());
2130 }
2131
2132 #[tokio::test]
2133 async fn trampoline_null_user_data_returns_minus_one() {
2134 let args_ref = StbStringRef::from_str("{}");
2135 let mut out = StbString::empty();
2136 let rc = trampoline_runtime_action(
2137 RuntimeActionId::GetSystemPrompt.into(),
2138 args_ref,
2139 &mut out as *mut StbString,
2140 std::ptr::null_mut(),
2141 );
2142 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
2143 }
2144
2145 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2146 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
2147 use std::sync::atomic::{AtomicUsize, Ordering};
2151 let reload_calls = Arc::new(AtomicUsize::new(0));
2152 let reload_calls_for_cb = Arc::clone(&reload_calls);
2153 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
2154 Arc::new(move || {
2155 let c = Arc::clone(&reload_calls_for_cb);
2156 Box::pin(async move {
2157 c.fetch_add(1, Ordering::SeqCst);
2158 })
2159 });
2160 let host = Arc::new(MockHost {
2161 prompt: String::new(),
2162 saw: Mutex::new(Vec::new()),
2163 });
2164 let host_for_assert = Arc::clone(&host);
2165 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2166 let runtime = tokio::runtime::Handle::current();
2167 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
2168 let user_data = bridge.user_data();
2169
2170 let args_ref = StbStringRef::from_str("{}");
2171 let mut out = StbString::empty();
2172 let rc = trampoline_runtime_action(
2173 RuntimeActionId::Reload.into(),
2174 args_ref,
2175 &mut out as *mut StbString,
2176 user_data,
2177 );
2178 assert_eq!(rc, 0);
2179 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
2181 assert!(host_for_assert.saw.lock().unwrap().is_empty());
2182 drop(unsafe {
2184 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2185 });
2186 }
2187
2188 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2193 async fn trampoline_rejects_stale_bridge_with_invalidate() {
2194 let host = Arc::new(MockHost {
2195 prompt: String::new(),
2196 saw: Mutex::new(Vec::new()),
2197 });
2198 let host_for_assert = Arc::clone(&host);
2199 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2200 let runtime = tokio::runtime::Handle::current();
2201 let bridge = ActionBridge::new(runtime, host_dyn);
2202 let user_data = bridge.user_data();
2203
2204 bridge.invalidate();
2206 assert!(!bridge.is_active());
2207
2208 let args_ref = StbStringRef::from_str("{}");
2209 let mut out = StbString::empty();
2210 let rc = trampoline_runtime_action(
2211 RuntimeActionId::GetSystemPrompt.into(),
2212 args_ref,
2213 &mut out as *mut StbString,
2214 user_data,
2215 );
2216 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
2218 let json_text = unsafe {
2220 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2221 }
2222 .to_string_lossy();
2223 assert!(
2224 json_text.contains("stale"),
2225 "stale-bridge error payload: {json_text}"
2226 );
2227
2228 assert!(
2229 host_for_assert.saw.lock().unwrap().is_empty(),
2230 "host dispatch must NOT run on a stale bridge"
2231 );
2232 }
2233
2234 #[tokio::test]
2235 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
2236 let host = Arc::new(MockHost {
2237 prompt: String::new(),
2238 saw: Mutex::new(Vec::new()),
2239 });
2240 let host_for_assert = Arc::clone(&host);
2241 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2242 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2243 let user_data = bridge.user_data();
2244
2245 let mut out = StbString::empty();
2246 let rc = trampoline_runtime_action(
2247 0xFFFF_FFFE,
2248 StbStringRef::from_str("{}"),
2249 &mut out,
2250 user_data,
2251 );
2252
2253 assert_eq!(rc, 2, "unknown action id must be a protocol error");
2254 let payload: serde_json::Value = serde_json::from_str(
2255 &unsafe {
2256 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2257 }
2258 .to_string_lossy(),
2259 )
2260 .expect("structured error JSON");
2261 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
2262
2263 assert!(
2264 host_for_assert.saw.lock().unwrap().is_empty(),
2265 "unknown action id must not reach host dispatch"
2266 );
2267 }
2268
2269 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2270 async fn v2_trampoline_accepts_new_action() {
2271 let host = Arc::new(MockHost {
2272 prompt: "v2".to_string(),
2273 saw: Mutex::new(Vec::new()),
2274 });
2275 let host_for_assert = Arc::clone(&host);
2276 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2277 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2278 let user_data = bridge.user_data();
2279
2280 let mut v2_out = StbString::empty();
2281 assert_eq!(
2282 trampoline_runtime_action(
2283 RuntimeActionId::GetCliFlag.into(),
2284 StbStringRef::from_str(r#"{"name":"flag"}"#),
2285 &mut v2_out,
2286 user_data,
2287 ),
2288 0
2289 );
2290 drop(unsafe {
2292 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
2293 });
2294
2295 assert_eq!(
2296 *host_for_assert.saw.lock().unwrap(),
2297 vec![RuntimeActionId::GetCliFlag]
2298 );
2299 }
2300
2301 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2306 async fn reload_mailbox_signals_installed_receiver() {
2307 let mailbox = ReloadMailbox::new();
2308 assert!(matches!(mailbox.signal(), Err(())));
2310
2311 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2312 mailbox.install(tx);
2313 let cb = reload_callback_from_mailbox(mailbox.clone());
2314 cb().await;
2315 assert_eq!(
2316 rx.recv().await,
2317 Some(()),
2318 "installed receiver saw the signal"
2319 );
2320 mailbox.clear();
2321 }
2322
2323 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
2326 serde_json::json!({
2327 "op": "open",
2328 "requestId": id,
2329 "toolCallId": tool_call,
2330 "ui": {"kind": "selector", "questions": [
2331 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
2332 ]},
2333 })
2334 }
2335
2336 #[test]
2339 fn ui_dialog_open_requires_an_attached_ui() {
2340 let mailbox = UiDialogMailbox::new();
2341 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
2342 assert!(error.contains("interactive UI"), "got: {error}");
2343 }
2344
2345 #[test]
2346 fn ui_dialog_open_poll_answer_round_trip() {
2347 let mailbox = UiDialogMailbox::new();
2348 mailbox.attach();
2349
2350 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
2351 assert_eq!(opened["status"], "pending");
2352 assert_eq!(opened["toolCallId"], "c1");
2353
2354 let request = mailbox.take_pending().expect("request visible to TUI");
2355 assert_eq!(request.request_id, "r1");
2356 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
2357 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2359 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2360
2361 mailbox
2362 .respond("r1", serde_json::json!("7890"))
2363 .expect("respond");
2364 let answered = mailbox.poll("r1").unwrap();
2365 assert_eq!(answered["status"], "answered");
2366 assert_eq!(answered["answer"], "7890");
2367 assert!(mailbox.poll("r1").is_err());
2369 }
2370
2371 #[test]
2372 fn ui_dialog_cancel_is_reported_once() {
2373 let mailbox = UiDialogMailbox::new();
2374 mailbox.attach();
2375 mailbox.handle(open_args("r1", "c1")).unwrap();
2376 mailbox.cancel("r1").unwrap();
2377 let cancelled = mailbox.poll("r1").unwrap();
2378 assert_eq!(cancelled["status"], "cancelled");
2379 assert!(mailbox.poll("r1").is_err());
2380 }
2381
2382 #[test]
2383 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
2384 let mailbox = UiDialogMailbox::new();
2385 mailbox.attach();
2386 mailbox.handle(open_args("r1", "c1")).unwrap();
2387 mailbox.handle(open_args("r2", "c2")).unwrap();
2388 mailbox.cancel_tool_call("c1");
2389 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2390 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
2391 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
2392 assert_eq!(
2393 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
2394 "cancelled"
2395 );
2396 assert!(mailbox.take_pending().is_none());
2397 }
2398
2399 #[test]
2400 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
2401 let mailbox = UiDialogMailbox::new();
2402 mailbox.attach();
2403 mailbox.handle(open_args("r1", "c1")).unwrap();
2404 mailbox.handle(open_args("r2", "c2")).unwrap();
2405
2406 let first = mailbox.take_pending().unwrap().request_id;
2407 let second = mailbox.take_pending().unwrap().request_id;
2408 assert_ne!(first, second);
2409
2410 mailbox
2412 .respond(&second, serde_json::json!("second"))
2413 .unwrap();
2414 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
2415 let second_answer = mailbox.poll(&second).unwrap();
2416 assert_eq!(second_answer["answer"], "second");
2417
2418 mailbox.respond(&first, serde_json::json!("first")).unwrap();
2419 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
2420 }
2421
2422 #[test]
2423 fn ui_dialog_detach_cancels_outstanding() {
2424 let mailbox = UiDialogMailbox::new();
2425 mailbox.attach();
2426 mailbox.handle(open_args("r1", "c1")).unwrap();
2427 mailbox.detach();
2428 assert!(!mailbox.is_attached());
2429 assert_eq!(mailbox.pending_len(), 0);
2430 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
2432 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2433 }
2434
2435 #[test]
2436 fn ui_dialog_duplicate_open_does_not_reenqueue() {
2437 let mailbox = UiDialogMailbox::new();
2438 mailbox.attach();
2439 mailbox.handle(open_args("r1", "c1")).unwrap();
2440 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
2441 assert_eq!(again["status"], "pending");
2442 assert_eq!(mailbox.pending_len(), 1);
2443 mailbox.take_pending().unwrap();
2444 assert!(mailbox.take_pending().is_none());
2445 }
2446
2447 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2452 async fn list_commands_routes_to_the_host_and_defaults_to_empty() {
2453 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2454 prompt: String::new(),
2455 saw: Mutex::new(Vec::new()),
2456 });
2457 let bridge = ActionBridge::with_ui_dialog(
2458 tokio::runtime::Handle::current(),
2459 host,
2460 UiDialogMailbox::new(),
2461 );
2462 let user_data = bridge.user_data();
2463 let mut out = StbString::empty();
2464 let rc = trampoline_runtime_action(
2465 u32::from(RuntimeActionId::ListCommands),
2466 StbStringRef::from_str("{}"),
2467 &mut out,
2468 user_data,
2469 );
2470 assert_eq!(
2471 rc, 0,
2472 "ListCommands must not fail on a host with no registry"
2473 );
2474 let payload: serde_json::Value = serde_json::from_str(
2475 &unsafe {
2476 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2477 }
2478 .to_string_lossy(),
2479 )
2480 .expect("JSON");
2481 assert_eq!(
2482 payload
2483 .get("commands")
2484 .and_then(|c| c.as_array())
2485 .map(Vec::len),
2486 Some(0),
2487 "an empty registry is an empty list, not an error: {payload}"
2488 );
2489 }
2490
2491 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2495 async fn v2_dispatch_routes_ui_dialog_action_17() {
2496 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2497 prompt: String::new(),
2498 saw: Mutex::new(Vec::new()),
2499 });
2500 let bridge = ActionBridge::with_ui_dialog(
2501 tokio::runtime::Handle::current(),
2502 host,
2503 UiDialogMailbox::new(),
2504 );
2505 let user_data = bridge.user_data();
2506
2507 let mut out = StbString::empty();
2508 let rc = trampoline_runtime_action(
2509 17,
2510 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
2511 &mut out,
2512 user_data,
2513 );
2514 assert_eq!(rc, 1);
2515 let payload: serde_json::Value = serde_json::from_str(
2516 &unsafe {
2517 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2518 }
2519 .to_string_lossy(),
2520 )
2521 .expect("structured error JSON");
2522 assert!(
2523 payload["error"]
2524 .as_str()
2525 .is_some_and(|error| error.contains("interactive UI")),
2526 "unexpected payload: {payload}"
2527 );
2528
2529 let one_past_last = rpi_plugin_sdk::RuntimeActionId::MAX + 1;
2533 let mut out = StbString::empty();
2534 assert_eq!(
2535 trampoline_runtime_action(
2536 one_past_last,
2537 StbStringRef::from_str("{}"),
2538 &mut out,
2539 user_data
2540 ),
2541 2
2542 );
2543 drop(unsafe {
2545 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2546 });
2547 }
2548
2549 #[tokio::test(flavor = "current_thread")]
2550 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
2551 let host = Arc::new(MockHost {
2552 prompt: String::new(),
2553 saw: Mutex::new(Vec::new()),
2554 });
2555 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
2556 let a = root.for_plugin("a");
2557 let b = root.for_plugin("b");
2558 let mailbox = root.extension_status_mailbox();
2559 for plugin in [&a, &b] {
2560 let mut out = StbString::empty();
2561 assert_eq!(
2562 trampoline_runtime_action(
2563 22,
2564 StbStringRef::from_str(
2565 r#"{"op":"register","id":"same","slot":"footer.after"}"#
2566 ),
2567 &mut out,
2568 plugin.user_data()
2569 ),
2570 0
2571 );
2572 drop(unsafe {
2574 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2575 });
2576 }
2577 assert_eq!(mailbox.panels().len(), 2);
2578 drop(a);
2579 assert_eq!(mailbox.panels().len(), 1);
2580 root.invalidate();
2581 assert!(mailbox.panels().is_empty());
2582 }
2583
2584 #[test]
2590 fn relay_ui_dialog_runs_off_the_executor() {
2591 let mailbox = UiDialogMailbox::new();
2592 mailbox.attach();
2593 let out = dispatch_relay_ui_dialog(&mailbox, serde_json::json!({"op":"release"}))
2594 .expect("release is a supported op");
2595 assert_eq!(out["requeued"], serde_json::json!(0));
2596 }
2597
2598 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2601 async fn v2_dispatch_routes_set_status_action_18() {
2602 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2603 prompt: String::new(),
2604 saw: Mutex::new(Vec::new()),
2605 });
2606 let status = crate::status::ExtensionStatusMailbox::new();
2607 let bridge = ActionBridge::with_reload_and_ui(
2608 tokio::runtime::Handle::current(),
2609 host,
2610 Arc::new(|| Box::pin(async {})),
2611 UiDialogMailbox::new(),
2612 status.clone(),
2613 crate::editor_text::EditorTextMailbox::new(),
2614 );
2615 let user_data = bridge.user_data();
2616
2617 let mut out = StbString::empty();
2618 let rc = trampoline_runtime_action(
2619 18,
2620 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2621 &mut out,
2622 user_data,
2623 );
2624 assert_eq!(rc, 0);
2625 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2626 drop(unsafe {
2628 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2629 });
2630
2631 let mut out = StbString::empty();
2633 assert_eq!(
2634 trampoline_runtime_action(
2635 18,
2636 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2637 &mut out,
2638 user_data
2639 ),
2640 0
2641 );
2642 assert_eq!(status.text(), "");
2643 drop(unsafe {
2645 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2646 });
2647
2648 let mut out = StbString::empty();
2650 assert_eq!(
2651 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2652 1
2653 );
2654 drop(unsafe {
2656 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2657 });
2658 }
2659 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2666 async fn trampoline_ceiling_matches_the_highest_defined_action() {
2667 let highest = RuntimeActionId::MAX;
2668 assert_eq!(
2669 highest,
2670 RuntimeActionId::ALL
2671 .iter()
2672 .copied()
2673 .map(u32::from)
2674 .max()
2675 .unwrap(),
2676 "RuntimeActionId::MAX must be the maximum of RuntimeActionId::ALL"
2677 );
2678 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2679 prompt: String::new(),
2680 saw: Mutex::new(Vec::new()),
2681 });
2682 let bridge = ActionBridge::with_ui_dialog(
2683 tokio::runtime::Handle::current(),
2684 host,
2685 UiDialogMailbox::new(),
2686 );
2687 let mut out = StbString::empty();
2688 let rc = trampoline_runtime_action(
2689 highest,
2690 StbStringRef::from_str("{}"),
2691 &mut out,
2692 bridge.user_data(),
2693 );
2694 assert_ne!(
2695 rc, 2,
2696 "the highest defined action id ({highest}) must not be rejected as unknown — \
2697 the trampoline ceiling is stale"
2698 );
2699 if rc == 0 {
2700 drop(unsafe {
2702 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2703 });
2704 }
2705 }
2706}
2707
2708impl Drop for ActionBridge {
2709 fn drop(&mut self) {
2710 bridges()
2711 .lock()
2712 .unwrap_or_else(|e| e.into_inner())
2713 .retain(|_, entry| !std::ptr::eq(entry.bridge.as_ptr(), self));
2714 if self.invocation.is_some() {
2715 return;
2716 }
2717 if let Some((owner, _)) = &self.plugin_identity {
2718 self.ext_status.remove_owner(*owner);
2719 self.host.cancel_child_agents(*owner);
2720 self.host.cancel_side_questions(*owner);
2721 }
2722 }
2723}
2724
2725#[cfg(test)]
2726mod runtime_tests;
2727
2728#[cfg(test)]
2729mod relay_tests {
2730 use super::*;
2731
2732 fn request(id: &str) -> UiDialogRequest {
2733 UiDialogRequest {
2734 request_id: id.to_string(),
2735 tool_call_id: Some(format!("tool-{id}")),
2736 ui: serde_json::json!({"question": "ok?"}),
2737 raw: serde_json::json!({}),
2738 }
2739 }
2740
2741 #[test]
2745 fn reserved_requests_are_invisible_to_the_tui() {
2746 let mailbox = UiDialogMailbox::new();
2747 mailbox.attach();
2748 let reserved = format!("{PLUGIN_RESERVED_PREFIX}gate-1");
2749 mailbox
2750 .handle(serde_json::json!({
2751 "op": "open",
2752 "requestId": reserved,
2753 "toolCallId": "t1",
2754 "ui": {}
2755 }))
2756 .unwrap();
2757 mailbox
2758 .handle(serde_json::json!({
2759 "op": "open",
2760 "requestId": "plain-1",
2761 "toolCallId": "t2",
2762 "ui": {}
2763 }))
2764 .unwrap();
2765
2766 let taken = mailbox
2768 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2769 .expect("the unreserved request must reach the TUI");
2770 assert_eq!(taken.request_id, "plain-1");
2771
2772 let own = mailbox
2774 .take_pending_filtered(|r| mailbox.is_reserved(&r.request_id))
2775 .expect("the plugin must still see its reserved request");
2776 assert_eq!(own.request_id, reserved);
2777 }
2778
2779 #[test]
2781 fn release_or_detach_clears_takeover_and_requeues_unanswered() {
2782 let mailbox = UiDialogMailbox::new();
2783 mailbox.attach();
2784 mailbox
2785 .handle(serde_json::json!({
2786 "op": "open", "requestId": "plain-r", "toolCallId": "t", "ui": {}
2787 }))
2788 .unwrap();
2789
2790 mailbox.set_external_takeover(true);
2792 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2793 assert_eq!(mailbox.release_takeover(), 1);
2794 assert!(
2795 !mailbox.external_takeover(),
2796 "release must hand delivery back to the TUI"
2797 );
2798 assert_eq!(
2800 mailbox
2801 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2802 .map(|r| r.request_id),
2803 Some("plain-r".to_string())
2804 );
2805
2806 mailbox.set_external_takeover(true);
2808 mailbox.detach();
2809 assert!(!mailbox.external_takeover(), "detach must clear takeover");
2810 }
2811
2812 #[test]
2815 fn tui_take_is_atomic_against_takeover() {
2816 let mailbox = UiDialogMailbox::new();
2817 mailbox.attach();
2818 mailbox.set_external_takeover(true);
2819 mailbox
2820 .handle(serde_json::json!({
2821 "op": "open", "requestId": "plain-a", "toolCallId": "t", "ui": {}
2822 }))
2823 .unwrap();
2824 assert!(
2825 mailbox.take_pending_unless_external(|_| true).is_none(),
2826 "the TUI must stand down while a relay owns delivery"
2827 );
2828 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2830 }
2831
2832 #[test]
2835 fn relay_take_returns_unreserved_and_keeps_reserved() {
2836 let mailbox = UiDialogMailbox::new();
2837 mailbox.attach();
2838 mailbox
2839 .handle(serde_json::json!({
2840 "op": "open",
2841 "requestId": format!("{PLUGIN_RESERVED_PREFIX}keep"),
2842 "toolCallId": "t1",
2843 "ui": {}
2844 }))
2845 .unwrap();
2846 mailbox
2847 .handle(serde_json::json!({
2848 "op": "open",
2849 "requestId": "relay-1",
2850 "toolCallId": "t2",
2851 "ui": {}
2852 }))
2853 .unwrap();
2854
2855 let taken = mailbox.take_all_unreserved();
2856 assert_eq!(taken.len(), 1);
2857 assert_eq!(taken[0].request_id, "relay-1");
2858 assert_eq!(mailbox.pending_len(), 1);
2860 }
2861
2862 #[test]
2865 fn a_relayed_request_can_still_be_answered() {
2866 let mailbox = UiDialogMailbox::new();
2867 mailbox.attach();
2868 mailbox
2869 .handle(serde_json::json!({
2870 "op": "open",
2871 "requestId": "relay-1",
2872 "toolCallId": "t1",
2873 "ui": {}
2874 }))
2875 .unwrap();
2876 let taken = mailbox.take_all_unreserved();
2877 assert_eq!(taken.len(), 1);
2878
2879 mailbox
2880 .handle(serde_json::json!({
2881 "op": "respond",
2882 "requestId": "relay-1",
2883 "answer": {"ok": true}
2884 }))
2885 .unwrap();
2886 assert_eq!(
2888 mailbox
2889 .take_pending_filtered(|_| true)
2890 .map(|r| r.request_id),
2891 None,
2892 "an answered request must not resurface"
2893 );
2894 }
2895
2896 #[test]
2899 fn take_pending_still_takes_reserved_entries() {
2900 let mailbox = UiDialogMailbox::new();
2901 mailbox.attach();
2902 let reserved = format!("{PLUGIN_RESERVED_PREFIX}x");
2903 mailbox
2904 .handle(serde_json::json!({
2905 "op": "open",
2906 "requestId": reserved,
2907 "toolCallId": "t1",
2908 "ui": {}
2909 }))
2910 .unwrap();
2911 assert_eq!(
2912 mailbox.take_pending().map(|r| r.request_id),
2913 Some(format!("{PLUGIN_RESERVED_PREFIX}x"))
2914 );
2915 }
2916
2917 #[test]
2923 fn external_takeover_makes_the_tui_stand_down() {
2924 let mailbox = UiDialogMailbox::new();
2925 mailbox.attach();
2926 mailbox
2927 .handle(serde_json::json!({
2928 "op": "open",
2929 "requestId": "plain-1",
2930 "toolCallId": "t1",
2931 "ui": {}
2932 }))
2933 .unwrap();
2934
2935 assert!(!mailbox.external_takeover());
2936 mailbox.set_external_takeover(true);
2937 assert!(mailbox.external_takeover());
2938
2939 let tui_take = |m: &UiDialogMailbox| {
2941 if m.external_takeover() {
2942 return None;
2943 }
2944 m.take_pending_filtered(|r| !m.is_reserved(&r.request_id))
2945 };
2946 assert!(
2947 tui_take(&mailbox).is_none(),
2948 "the TUI must not steal a request an external UI owns"
2949 );
2950 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2952 }
2953
2954 #[test]
2956 fn clearing_takeover_returns_dialogs_to_the_tui() {
2957 let mailbox = UiDialogMailbox::new();
2958 mailbox.attach();
2959 mailbox.set_external_takeover(true);
2960 mailbox.set_external_takeover(false);
2961 mailbox
2962 .handle(serde_json::json!({
2963 "op": "open",
2964 "requestId": "plain-2",
2965 "toolCallId": "t2",
2966 "ui": {}
2967 }))
2968 .unwrap();
2969 assert_eq!(
2970 mailbox
2971 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2972 .map(|r| r.request_id),
2973 Some("plain-2".to_string())
2974 );
2975 }
2976
2977 #[test]
2978 fn request_helper_matches_the_prefix_rule() {
2979 let mailbox = UiDialogMailbox::new();
2980 mailbox.attach();
2981 assert!(mailbox.is_reserved(&request(PLUGIN_RESERVED_PREFIX).request_id));
2982 assert!(!mailbox.is_reserved(&request("plain").request_id));
2983 }
2984}