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
36static BRIDGES: OnceLock<Mutex<HashMap<usize, Weak<ActionBridge>>>> = OnceLock::new();
39fn bridges() -> &'static Mutex<HashMap<usize, Weak<ActionBridge>>> {
40 BRIDGES.get_or_init(Mutex::default)
41}
42thread_local! {
43 static CURRENT_INVOCATION: std::cell::RefCell<Option<Arc<ActionBridge>>> = const { std::cell::RefCell::new(None) };
44}
45pub(crate) struct InvocationGuard {
46 previous: Option<Arc<ActionBridge>>,
47 _thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
48}
49impl Drop for InvocationGuard {
50 fn drop(&mut self) {
51 CURRENT_INVOCATION.with(|slot| *slot.borrow_mut() = self.previous.take());
52 }
53}
54
55use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
56use tokio::runtime::Handle;
57
58pub const PLUGIN_RESERVED_PREFIX: &str = "pidingo:";
68
69#[derive(Clone, Debug, PartialEq)]
76pub struct UiDialogRequest {
77 pub request_id: String,
78 pub tool_call_id: Option<String>,
79 pub ui: serde_json::Value,
80 pub raw: serde_json::Value,
81}
82
83struct UiDialogEntry {
84 request: UiDialogRequest,
85 answer: Option<serde_json::Value>,
86 cancelled: bool,
87}
88
89struct UiDialogState {
90 attached: bool,
91 external_takeover: bool,
98 pending: VecDeque<String>,
99 active: HashMap<String, UiDialogEntry>,
100 cancelled_tool_calls: std::collections::HashSet<String>,
101}
102
103#[derive(Clone)]
107pub struct UiDialogMailbox {
108 state: Arc<Mutex<UiDialogState>>,
109}
110
111impl Default for UiDialogMailbox {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl UiDialogMailbox {
118 pub fn new() -> Self {
119 Self {
120 state: Arc::new(Mutex::new(UiDialogState {
121 attached: false,
122 external_takeover: false,
123 pending: VecDeque::new(),
124 active: HashMap::new(),
125 cancelled_tool_calls: std::collections::HashSet::new(),
126 })),
127 }
128 }
129
130 pub fn attach(&self) {
132 self.state
133 .lock()
134 .expect("ui dialog mailbox poisoned")
135 .attached = true;
136 }
137
138 pub fn set_external_takeover(&self, takeover: bool) {
149 self.state
150 .lock()
151 .expect("ui dialog mailbox poisoned")
152 .external_takeover = takeover;
153 }
154
155 pub fn external_takeover(&self) -> bool {
157 self.state
158 .lock()
159 .expect("ui dialog mailbox poisoned")
160 .external_takeover
161 }
162
163 pub fn detach(&self) {
166 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
167 state.attached = false;
168 state.external_takeover = false;
172 state.pending.clear();
173 for entry in state.active.values_mut() {
174 entry.cancelled = true;
175 entry.answer = None;
176 }
177 }
178
179 pub fn release_takeover(&self) -> usize {
188 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
189 state.external_takeover = false;
190 let mut requeued: Vec<String> = state
191 .active
192 .iter()
193 .filter(|(_, entry)| !entry.cancelled && entry.answer.is_none())
194 .map(|(request_id, _)| request_id.clone())
195 .filter(|request_id| !state.pending.contains(request_id))
196 .collect();
197 if requeued.is_empty() {
198 return 0;
199 }
200 let count = requeued.len();
201 requeued.sort();
203 for request_id in requeued.into_iter().rev() {
204 state.pending.push_front(request_id);
205 }
206 count
207 }
208
209 pub fn is_attached(&self) -> bool {
210 self.state
211 .lock()
212 .expect("ui dialog mailbox poisoned")
213 .attached
214 }
215
216 pub fn take_pending(&self) -> Option<UiDialogRequest> {
220 self.take_pending_filtered(|_| true)
221 }
222
223 pub fn take_pending_unless_external<F>(&self, keep: F) -> Option<UiDialogRequest>
232 where
233 F: Fn(&UiDialogRequest) -> bool,
234 {
235 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
236 if state.external_takeover {
237 return None;
238 }
239 Self::take_pending_locked(&mut state, keep)
240 }
241
242 pub fn take_pending_filtered<F>(&self, keep: F) -> Option<UiDialogRequest>
257 where
258 F: Fn(&UiDialogRequest) -> bool,
259 {
260 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
261 Self::take_pending_locked(&mut state, keep)
262 }
263
264 fn take_pending_locked<F>(
265 state: &mut std::sync::MutexGuard<'_, UiDialogState>,
266 keep: F,
267 ) -> Option<UiDialogRequest>
268 where
269 F: Fn(&UiDialogRequest) -> bool,
270 {
271 let mut scanned = 0usize;
272 let total = state.pending.len();
273 while scanned < total {
274 scanned += 1;
275 let Some(request_id) = state.pending.pop_front() else {
276 break;
277 };
278 let Some(entry) = state.active.get(&request_id) else {
279 continue;
280 };
281 if entry.cancelled || entry.answer.is_some() {
282 continue;
283 }
284 let request = entry.request.clone();
285 if keep(&request) {
286 return Some(request);
287 }
288 state.pending.push_back(request_id);
290 }
291 None
292 }
293
294 pub fn take_all_unreserved(&self) -> Vec<UiDialogRequest> {
303 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
304 let mut out: Vec<UiDialogRequest> = Vec::new();
305 let drained = std::mem::take(&mut state.pending);
307 for request_id in drained {
308 let Some(entry) = state.active.get(&request_id) else {
309 continue;
310 };
311 if entry.cancelled || entry.answer.is_some() {
312 continue;
313 }
314 if request_id.starts_with(PLUGIN_RESERVED_PREFIX) {
315 state.pending.push_back(request_id);
316 } else {
317 out.push(entry.request.clone());
318 }
319 }
320 out
321 }
322
323 pub fn is_reserved(&self, request_id: &str) -> bool {
325 request_id.starts_with(PLUGIN_RESERVED_PREFIX)
326 }
327
328 pub fn pending_len(&self) -> usize {
329 self.state
330 .lock()
331 .expect("ui dialog mailbox poisoned")
332 .pending
333 .len()
334 }
335
336 pub fn is_pending(&self, request_id: &str) -> bool {
338 self.state
339 .lock()
340 .expect("ui dialog mailbox poisoned")
341 .active
342 .get(request_id)
343 .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
344 }
345
346 pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
349 let mut state = self
350 .state
351 .lock()
352 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
353 let entry = state
354 .active
355 .get_mut(request_id)
356 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
357 if entry.cancelled {
358 return Err(format!("UI request `{request_id}` was cancelled"));
359 }
360 if entry.answer.is_none() {
361 entry.answer = Some(answer);
362 }
363 Ok(())
364 }
365
366 pub fn cancel(&self, request_id: &str) -> Result<(), String> {
369 let mut state = self
370 .state
371 .lock()
372 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
373 let entry = state
374 .active
375 .get_mut(request_id)
376 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
377 entry.cancelled = true;
378 entry.answer = None;
379 state.pending.retain(|id| id != request_id);
380 Ok(())
381 }
382
383 pub fn cancel_all(&self) {
385 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
386 state.pending.clear();
387 for entry in state.active.values_mut() {
388 entry.cancelled = true;
389 entry.answer = None;
390 }
391 }
392
393 pub fn cancel_tool_call(&self, tool_call_id: &str) {
396 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
397 state.cancelled_tool_calls.insert(tool_call_id.to_string());
400 for entry in state.active.values_mut() {
401 if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
402 entry.cancelled = true;
403 entry.answer = None;
404 }
405 }
406 let cancelled: std::collections::HashSet<String> = state
407 .active
408 .iter()
409 .filter(|(_, entry)| entry.cancelled)
410 .map(|(id, _)| id.clone())
411 .collect();
412 state.pending.retain(|id| !cancelled.contains(id));
413 }
414
415 pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
418 let mut state = self
419 .state
420 .lock()
421 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
422 let terminal = match state.active.get(request_id) {
423 Some(entry) if entry.cancelled => Some(serde_json::json!({
424 "status": "cancelled",
425 "requestId": request_id,
426 "toolCallId": entry.request.tool_call_id,
427 })),
428 Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
429 "status": "answered",
430 "requestId": request_id,
431 "toolCallId": entry.request.tool_call_id,
432 "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
433 })),
434 Some(entry) => Some(serde_json::json!({
435 "status": "pending",
436 "requestId": request_id,
437 "toolCallId": entry.request.tool_call_id,
438 })),
439 None => None,
440 };
441 let Some(result) = terminal else {
442 return Err(format!("unknown UI request `{request_id}`"));
443 };
444 if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
445 state.active.remove(request_id);
446 }
447 Ok(result)
448 }
449
450 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
454 let op = args
455 .get("op")
456 .and_then(serde_json::Value::as_str)
457 .unwrap_or("open");
458 let request_id = || {
459 args.get("requestId")
460 .or_else(|| args.get("request_id"))
461 .and_then(serde_json::Value::as_str)
462 .map(str::to_owned)
463 .filter(|id| !id.trim().is_empty())
464 .ok_or_else(|| "missing non-empty `requestId`".to_string())
465 };
466 match op {
467 "open" => {
468 let id = request_id()?;
469 let mut state = self
470 .state
471 .lock()
472 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
473 if !state.attached {
474 return Err("ask_user requires an interactive UI".to_string());
475 }
476 if let Some(entry) = state.active.get(&id) {
477 return Ok(if entry.cancelled {
478 serde_json::json!({"status":"cancelled","requestId":id})
479 } else if let Some(answer) = &entry.answer {
480 serde_json::json!({"status":"answered","requestId":id,"answer":answer})
481 } else {
482 serde_json::json!({"status":"pending","requestId":id})
483 });
484 }
485 let tool_call_id = args
486 .get("toolCallId")
487 .or_else(|| args.get("tool_call_id"))
488 .and_then(serde_json::Value::as_str)
489 .map(str::to_owned);
490 if tool_call_id
491 .as_ref()
492 .is_some_and(|id| state.cancelled_tool_calls.contains(id))
493 {
494 return Ok(serde_json::json!({
495 "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
496 }));
497 }
498 let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
499 rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
500 state.active.insert(
501 id.clone(),
502 UiDialogEntry {
503 request: UiDialogRequest {
504 request_id: id.clone(),
505 tool_call_id: tool_call_id.clone(),
506 ui,
507 raw: args,
508 },
509 answer: None,
510 cancelled: false,
511 },
512 );
513 state.pending.push_back(id.clone());
514 Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
515 }
516 "poll" => self.poll(&request_id()?),
517 "cancel" => {
518 let id = request_id()?;
519 self.cancel(&id)?;
520 Ok(serde_json::json!({"status":"cancelled","requestId":id}))
521 }
522 "response" | "respond" => {
523 let id = request_id()?;
524 let answer = args
525 .get("answer")
526 .or_else(|| args.get("value"))
527 .cloned()
528 .unwrap_or(serde_json::Value::Null);
529 self.respond(&id, answer)?;
530 Ok(serde_json::json!({"status":"answered","requestId":id}))
531 }
532 other => Err(format!("unknown UI dialog operation `{other}`")),
533 }
534 }
535}
536
537pub type SideQuestionSink =
539 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>;
540
541#[async_trait::async_trait]
556pub trait RuntimeActionHost: Send + Sync {
557 fn cancel_side_questions(&self, _owner: u64) {}
559 fn side_question_revision(&self) -> u64 {
561 0
562 }
563 fn side_question_views(&self) -> Vec<serde_json::Value> {
564 Vec::new()
565 }
566 async fn side_question_ui(
567 &self,
568 _owner: u64,
569 _thread_id: String,
570 _args: serde_json::Value,
571 ) -> Result<serde_json::Value, String> {
572 Err("SideQuestion UI is not supported by this host".into())
573 }
574 async fn side_question(
576 &self,
577 _owner: u64,
578 _args: serde_json::Value,
579 _sink: SideQuestionSink,
580 ) -> Result<serde_json::Value, String> {
581 Err("SideQuestion is not supported by this host; upgrade rpi".into())
582 }
583
584 fn replace_model_catalog(&self, _models: Vec<rpi_ai::Model>) {}
586 fn cancel_child_agents(&self, _owner: u64) {}
588 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
593 async fn send_user_message(&self, args: serde_json::Value)
595 -> Result<serde_json::Value, String>;
596 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
606 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
608 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
610 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
612 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
614 async fn get_thinking_level(
616 &self,
617 args: serde_json::Value,
618 ) -> Result<serde_json::Value, String>;
619 async fn set_thinking_level(
621 &self,
622 args: serde_json::Value,
623 ) -> Result<serde_json::Value, String>;
624 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
626 async fn get_system_prompt(&self, args: serde_json::Value)
628 -> Result<serde_json::Value, String>;
629 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
631 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
633 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
635 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
637 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
641
642 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
645 Err("CLI flag lookup is not configured".to_string())
646 }
647
648 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
652 Err("ask_user requires an interactive UI".to_string())
653 }
654
655 async fn list_commands(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
666 Ok(serde_json::json!({ "commands": [] }))
667 }
668
669 fn publish_registry(&self, _snapshot: std::sync::Arc<crate::registry::RegistrySnapshot>) {}
680
681 fn publish_registry_opt(
687 &self,
688 snapshot: Option<std::sync::Arc<crate::registry::RegistrySnapshot>>,
689 ) {
690 if let Some(snapshot) = snapshot {
691 self.publish_registry(snapshot);
692 }
693 }
694
695 async fn abort(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
703 Ok(serde_json::json!({
704 "aborted": false,
705 "steer": [],
706 "followUp": [],
707 }))
708 }
709
710 fn stop_round(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
720 Ok(serde_json::json!({ "requested": true }))
721 }
722
723 async fn get_execution_context(
725 &self,
726 _args: serde_json::Value,
727 ) -> Result<serde_json::Value, String> {
728 Err("execution context is not supported by this host".into())
729 }
730
731 async fn child_agent(
733 &self,
734 _owner: u64,
735 _args: serde_json::Value,
736 ) -> Result<serde_json::Value, String> {
737 Err("ChildAgent is not supported by this host".into())
738 }
739
740 async fn get_session_branch(
750 &self,
751 _args: serde_json::Value,
752 ) -> Result<serde_json::Value, String> {
753 Err("session branch lookup is not configured".to_string())
754 }
755}
756
757pub struct ActionBridge {
784 invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
786 execution_context: Option<serde_json::Value>,
787 stdio: crate::StdioLifecycle,
788 plugin_identity: Option<(u64, String)>,
789 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
790 pub(crate) runtime: Handle,
792 pub(crate) host: Arc<dyn RuntimeActionHost>,
794 pub(crate) reload:
796 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
797 ui_dialog: UiDialogMailbox,
800 ext_status: crate::status::ExtensionStatusMailbox,
804 editor_text: crate::editor_text::EditorTextMailbox,
808 active: Arc<AtomicBool>,
811}
812
813impl ActionBridge {
814 pub fn user_data(self: &Arc<Self>) -> *mut c_void {
816 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
817 let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
818 if let Some((&id, _)) = registry
819 .iter()
820 .find(|(_, weak)| weak.as_ptr() == Arc::as_ptr(self))
821 {
822 return id as *mut c_void;
823 }
824 let mut id = NEXT.load(Ordering::Relaxed);
825 loop {
826 let next = id.checked_add(1).expect("runtime handle space exhausted");
827 match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
828 Ok(_) => break,
829 Err(current) => id = current,
830 }
831 }
832 registry.insert(id, Arc::downgrade(self));
833 id as *mut c_void
834 }
835 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
836 bridges()
837 .lock()
838 .unwrap_or_else(|e| e.into_inner())
839 .get(&id)
840 .and_then(Weak::upgrade)
841 }
842 pub(crate) fn for_invocation(
843 &self,
844 child: bool,
845 signal: tokio_util::sync::CancellationToken,
846 ) -> Arc<Self> {
847 Arc::new(Self {
848 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
849 execution_context: rpi_agent::tool_execution_scope::execution_context(),
850 stdio: self.stdio.clone(),
851 plugin_identity: self.plugin_identity.clone(),
852 component_owners: self.component_owners.clone(),
853 runtime: self.runtime.clone(),
854 host: self.host.clone(),
855 reload: self.reload.clone(),
856 ui_dialog: self.ui_dialog.clone(),
857 ext_status: self.ext_status.clone(),
858 editor_text: self.editor_text.clone(),
859 active: self.active.clone(),
860 })
861 }
862 pub(crate) fn invocation_key(&self) -> Option<&str> {
863 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
864 }
865 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
866 InvocationGuard {
867 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
868 _thread_bound: std::marker::PhantomData,
869 }
870 }
871 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
874 Arc::new(Self {
875 invocation: None,
876 execution_context: None,
877 stdio: crate::StdioLifecycle::default(),
878 plugin_identity: None,
879 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
880 runtime,
881 host,
882 reload: None,
883 ui_dialog: UiDialogMailbox::new(),
884 ext_status: crate::status::ExtensionStatusMailbox::new(),
885 editor_text: crate::editor_text::EditorTextMailbox::new(),
886 active: Arc::new(AtomicBool::new(true)),
887 })
888 }
889
890 pub fn with_reload(
893 runtime: Handle,
894 host: Arc<dyn RuntimeActionHost>,
895 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
896 ) -> Arc<Self> {
897 Self::with_reload_and_ui(
898 runtime,
899 host,
900 reload,
901 UiDialogMailbox::new(),
902 crate::status::ExtensionStatusMailbox::new(),
903 crate::editor_text::EditorTextMailbox::new(),
904 )
905 }
906
907 pub fn with_ui_dialog(
909 runtime: Handle,
910 host: Arc<dyn RuntimeActionHost>,
911 ui_dialog: UiDialogMailbox,
912 ) -> Arc<Self> {
913 Arc::new(Self {
914 invocation: None,
915 execution_context: None,
916 stdio: crate::StdioLifecycle::default(),
917 plugin_identity: None,
918 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
919 runtime,
920 host,
921 reload: None,
922 ui_dialog,
923 ext_status: crate::status::ExtensionStatusMailbox::new(),
924 editor_text: crate::editor_text::EditorTextMailbox::new(),
925 active: Arc::new(AtomicBool::new(true)),
926 })
927 }
928
929 pub fn with_reload_and_ui(
931 runtime: Handle,
932 host: Arc<dyn RuntimeActionHost>,
933 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
934 ui_dialog: UiDialogMailbox,
935 ext_status: crate::status::ExtensionStatusMailbox,
936 editor_text: crate::editor_text::EditorTextMailbox,
937 ) -> Arc<Self> {
938 Arc::new(Self {
939 invocation: None,
940 execution_context: None,
941 stdio: crate::StdioLifecycle::default(),
942 plugin_identity: None,
943 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
944 runtime,
945 host,
946 reload: Some(reload),
947 ui_dialog,
948 ext_status,
949 editor_text,
950 active: Arc::new(AtomicBool::new(true)),
951 })
952 }
953
954 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
955 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
956 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
957 self.component_owners
958 .lock()
959 .unwrap_or_else(|e| e.into_inner())
960 .push(owner);
961 Arc::new(Self {
962 invocation: None,
963 execution_context: None,
964 component_owners: self.component_owners.clone(),
965 stdio: self.stdio.clone(),
966 plugin_identity: Some((owner, name.to_owned())),
967 runtime: self.runtime.clone(),
968 host: self.host.clone(),
969 reload: self.reload.clone(),
970 ui_dialog: self.ui_dialog.clone(),
971 ext_status: self.ext_status.clone(),
972 editor_text: self.editor_text.clone(),
973 active: self.active.clone(),
974 })
975 }
976
977 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
978 self.stdio.clone()
979 }
980
981 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
982 let (id, name) = self
983 .plugin_identity
984 .as_ref()
985 .ok_or("missing plugin identity")?;
986 self.stdio.claim(*id, name)
987 }
988
989 pub(crate) fn release_stdio_claim(&self) {
990 if let Some((id, _)) = &self.plugin_identity {
991 self.stdio.release_failed_registration(*id);
992 self.ext_status.remove_owner(*id);
993 }
994 }
995
996 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
998 self.ui_dialog.clone()
999 }
1000
1001 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
1003 self.ext_status.clone()
1004 }
1005
1006 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
1008 self.editor_text.clone()
1009 }
1010
1011 pub fn invalidate(&self) {
1016 let owners = {
1017 let owners = self
1018 .component_owners
1019 .lock()
1020 .unwrap_or_else(|e| e.into_inner());
1021 self.active.store(false, Ordering::SeqCst);
1022 for owner in owners.iter() {
1023 self.ext_status.remove_owner(*owner);
1024 }
1025 owners.clone()
1026 };
1027 for owner in owners {
1030 self.host.cancel_child_agents(owner);
1031 self.host.cancel_side_questions(owner);
1032 }
1033 }
1034
1035 pub fn is_active(&self) -> bool {
1037 self.active.load(Ordering::SeqCst)
1038 }
1039
1040 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
1047 Arc::clone(&self.host)
1048 }
1049}
1050
1051#[derive(Clone)]
1064pub struct ReloadMailbox {
1065 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
1066}
1067
1068impl Default for ReloadMailbox {
1069 fn default() -> Self {
1070 Self {
1071 tx: Arc::new(std::sync::Mutex::new(None)),
1072 }
1073 }
1074}
1075
1076impl ReloadMailbox {
1077 pub fn new() -> Self {
1079 Self::default()
1080 }
1081
1082 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
1086 *self.tx.lock().unwrap() = Some(tx);
1087 }
1088
1089 pub fn signal(&self) -> Result<(), ()> {
1094 let g = self.tx.lock().unwrap();
1095 match &*g {
1096 Some(tx) => {
1097 let _ = tx.send(());
1098 Ok(())
1099 }
1100 None => Err(()),
1101 }
1102 }
1103
1104 pub fn clear(&self) {
1106 *self.tx.lock().unwrap() = None;
1107 }
1108}
1109
1110pub fn reload_callback_from_mailbox(
1117 mailbox: ReloadMailbox,
1118) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
1119 Arc::new(move || {
1120 let m = mailbox.clone();
1121 Box::pin(async move {
1122 let _ = m.signal();
1123 })
1124 })
1125}
1126
1127fn dispatch_relay_ui_dialog(
1142 ui_dialog: &UiDialogMailbox,
1143 args: serde_json::Value,
1144) -> Result<serde_json::Value, String> {
1145 let op = args
1146 .get("op")
1147 .and_then(serde_json::Value::as_str)
1148 .unwrap_or("take");
1149 match op {
1150 "take" => {
1151 ui_dialog.set_external_takeover(true);
1155 let requests: Vec<serde_json::Value> = ui_dialog
1156 .take_all_unreserved()
1157 .into_iter()
1158 .map(|request| {
1159 serde_json::json!({
1160 "requestId": request.request_id,
1161 "toolCallId": request.tool_call_id,
1162 "ui": request.ui,
1163 })
1164 })
1165 .collect();
1166 Ok(serde_json::json!({ "requests": requests }))
1167 }
1168 "release" => {
1171 let requeued = ui_dialog.release_takeover();
1172 Ok(serde_json::json!({ "requeued": requeued }))
1173 }
1174 "respond" | "cancel" => ui_dialog.handle(args),
1176 other => Err(format!("RelayUiDialog does not support op `{other}`")),
1177 }
1178}
1179
1180async fn dispatch(
1181 host: &Arc<dyn RuntimeActionHost>,
1182 ui_dialog: &UiDialogMailbox,
1183 ext_status: &crate::status::ExtensionStatusMailbox,
1184 editor_text: &crate::editor_text::EditorTextMailbox,
1185 action: RuntimeActionId,
1186 args: serde_json::Value,
1187) -> Result<serde_json::Value, String> {
1188 match action {
1189 RuntimeActionId::SendMessage => host.send_message(args).await,
1190 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
1191 RuntimeActionId::AppendEntry => host.append_entry(args).await,
1192 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
1193 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
1194 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
1195 RuntimeActionId::SetModel => host.set_model(args).await,
1196 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
1197 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
1198 RuntimeActionId::Compact => host.compact(args).await,
1199 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
1200 RuntimeActionId::NewSession => host.new_session(args).await,
1201 RuntimeActionId::Fork => host.fork(args).await,
1202 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
1203 RuntimeActionId::SwitchSession => host.switch_session(args).await,
1204 RuntimeActionId::Reload => host.reload(args).await,
1205 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
1206 RuntimeActionId::UiDialog => ui_dialog.handle(args),
1207 RuntimeActionId::SetStatus => ext_status.handle(args),
1210 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
1211 RuntimeActionId::SetEditorText => editor_text.handle(args),
1214 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
1215 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
1216 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
1217 RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
1218 RuntimeActionId::ListCommands => host.list_commands(args).await,
1219 RuntimeActionId::Abort => host.abort(args).await,
1220 RuntimeActionId::RelayUiDialog => dispatch_relay_ui_dialog(ui_dialog, args),
1224 RuntimeActionId::StopRound => host.stop_round(args),
1225 RuntimeActionId::StdioLifecycle => {
1226 Err("stdio lifecycle requires a plugin context".to_owned())
1227 }
1228 }
1229}
1230
1231pub extern "C" fn trampoline_runtime_action(
1255 action_id: u32,
1256 args_json: StbStringRef,
1257 out: *mut StbString,
1258 user_data: *mut c_void,
1259) -> i32 {
1260 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1261 run_action(
1262 action_id,
1263 rpi_plugin_sdk::RuntimeActionId::MAX,
1265 args_json,
1266 out,
1267 user_data,
1268 )
1269 }));
1270 match outcome {
1271 Ok(rc) => rc,
1272 Err(_) => {
1273 tracing::error!("runtime_action trampoline panicked — reporting host error");
1277 if !out.is_null() {
1278 unsafe {
1279 *out = StbString::from_string(
1280 r#"{"error":"runtime_action panicked"}"#.to_string(),
1281 );
1282 }
1283 }
1284 1
1285 }
1286 }
1287}
1288
1289fn run_action(
1291 action_id: u32,
1292 max_action_id: u32,
1293 args_json: StbStringRef,
1294 out: *mut StbString,
1295 user_data: *mut c_void,
1296) -> i32 {
1297 let action = match RuntimeActionId::try_from(action_id) {
1301 Ok(action) if action_id <= max_action_id => action,
1302 Ok(_) | Err(_) => {
1303 if !out.is_null() {
1304 let json = serde_json::json!({
1305 "error": format!("unknown runtime action id {action_id}")
1306 })
1307 .to_string();
1308 unsafe {
1312 *out = StbString::from_string(json);
1313 }
1314 }
1315 return 2;
1316 }
1317 };
1318
1319 if user_data.is_null() {
1320 return -1;
1321 }
1322 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1325 let invocation_thread = invocation.is_some();
1326 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1327 let Some(bridge) = bridge else {
1328 if !out.is_null() {
1329 unsafe {
1331 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1332 }
1333 }
1334 return 1;
1335 };
1336
1337 if !bridge.is_active() {
1345 if out.is_null() {
1346 return 1;
1347 }
1348 let json = serde_json::json!({
1349 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1350 })
1351 .to_string();
1352 unsafe {
1353 *out = StbString::from_string(json);
1354 }
1355 return 1;
1356 }
1357
1358 let args_str = unsafe { args_json.as_str() };
1361 let mut args: serde_json::Value = if args_str.is_empty() {
1362 serde_json::Value::Object(serde_json::Map::new())
1363 } else {
1364 serde_json::from_str(args_str)
1365 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1366 };
1367
1368 let runtime_key = args
1369 .as_object_mut()
1370 .and_then(|args| args.remove("__rpiRuntimeKey"));
1371
1372 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1377 let host = Arc::clone(&bridge.host);
1381 let ui_dialog = bridge.ui_dialog.clone();
1382 let ext_status = bridge.ext_status.clone();
1383 let editor_text = bridge.editor_text.clone();
1384 let reload_cb = bridge.reload.clone();
1385 let execution_context = bridge.execution_context.clone();
1386 if action == RuntimeActionId::StdioLifecycle {
1387 let result = bridge.stdio.handle(
1388 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1389 args,
1390 );
1391 let _ = tx.send(result);
1392 } else if action == RuntimeActionId::UiComponent {
1393 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1394 let _owners = bridge
1395 .component_owners
1396 .lock()
1397 .unwrap_or_else(|e| e.into_inner());
1398 let result = if bridge.is_active() {
1399 ext_status.component(owner, args)
1400 } else {
1401 Err("stale plugin bridge".into())
1402 };
1403 let _ = tx.send(result);
1404 } else if action == RuntimeActionId::SideQuestion {
1405 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1406 if owner == 0 || bridge.invocation.is_some() {
1407 let _ = tx.send(Err(
1408 "SideQuestion requires a plugin command, not a tool invocation".into(),
1409 ));
1410 } else {
1411 let weak = Arc::downgrade(&bridge);
1414 let sink: SideQuestionSink = Arc::new(move |args| {
1415 let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1416 let _owners = bridge
1417 .component_owners
1418 .lock()
1419 .unwrap_or_else(|e| e.into_inner());
1420 if !bridge.is_active() {
1421 return Err("stale side question bridge".into());
1422 }
1423 if args["op"] == "check" {
1424 return Ok(serde_json::json!({"ok":true}));
1425 }
1426 bridge.ext_status.component(owner, args)
1427 });
1428 bridge.runtime.spawn(async move {
1429 let _ = tx.send(host.side_question(owner, args, sink).await);
1430 });
1431 }
1432 } else if action == RuntimeActionId::ChildAgent {
1433 let cleanup = matches!(
1438 args.get("op").and_then(serde_json::Value::as_str),
1439 Some("poll" | "cancel" | "dispose")
1440 );
1441 let allowed = bridge
1442 .invocation
1443 .as_ref()
1444 .is_some_and(|(child, key, signal)| {
1445 !child
1446 && (cleanup || !signal.is_cancelled())
1447 && (invocation_thread
1448 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1449 == Some(key.as_str()))
1450 });
1451 if !allowed {
1452 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1453 } else {
1454 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1455 let signal = bridge
1456 .invocation
1457 .as_ref()
1458 .expect("authorized invocation")
1459 .2
1460 .clone();
1461 bridge.runtime.spawn(async move {
1462 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1463 signal,
1464 host.child_agent(owner, args),
1465 )
1466 .await;
1467 let _ = tx.send(result);
1468 });
1469 }
1470 } else if action == RuntimeActionId::SetStatus {
1471 std::thread::spawn(move || {
1476 let _ = tx.send(ext_status.handle(args));
1477 });
1478 } else if action == RuntimeActionId::SetEditorText {
1479 std::thread::spawn(move || {
1482 let _ = tx.send(editor_text.handle(args));
1483 });
1484 } else if action == RuntimeActionId::RelayUiDialog {
1485 std::thread::spawn(move || {
1491 let _ = tx.send(dispatch_relay_ui_dialog(&ui_dialog, args));
1492 });
1493 } else if action == RuntimeActionId::UiDialog {
1494 std::thread::spawn(move || {
1500 let _ = tx.send(ui_dialog.handle(args));
1501 });
1502 } else {
1503 bridge.runtime.spawn(async move {
1504 let mut r = if action == RuntimeActionId::Reload {
1505 if let Some(cb) = reload_cb {
1506 cb().await;
1507 Ok(serde_json::Value::Null)
1508 } else {
1509 host.reload(args).await
1510 }
1511 } else {
1512 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1513 };
1514 if action == RuntimeActionId::GetExecutionContext {
1515 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1516 (&mut r, execution_context)
1517 {
1518 if let Some(object) = context.as_object_mut() {
1519 object.extend(fields);
1520 }
1521 }
1522 }
1523 let _ = tx.send(r);
1526 });
1527 }
1528
1529 let received = if Handle::try_current()
1535 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1536 {
1537 tokio::task::block_in_place(|| rx.recv())
1538 } else {
1539 rx.recv()
1540 };
1541 let result = match received {
1542 Ok(r) => r,
1543 Err(_) => {
1544 return -2;
1549 }
1550 };
1551
1552 if out.is_null() {
1555 return match result {
1557 Ok(_) => 0,
1558 Err(_) => 1,
1559 };
1560 }
1561
1562 let (rc, payload) = match result {
1563 Ok(value) => {
1564 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1565 (0, json)
1566 }
1567 Err(msg) => {
1568 let json = serde_json::json!({ "error": msg }).to_string();
1569 (1, json)
1570 }
1571 };
1572 unsafe {
1578 *out = StbString::from_string(payload);
1579 }
1580 rc
1581}
1582
1583#[cfg(test)]
1592mod tests {
1593 use super::*;
1594 use std::sync::Mutex;
1595
1596 #[tokio::test(flavor = "current_thread")]
1597 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1598 let master = ActionBridge::new(
1599 Handle::current(),
1600 Arc::new(MockHost {
1601 prompt: String::new(),
1602 saw: Mutex::new(Vec::new()),
1603 }),
1604 );
1605 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1606 let owner = crate::HostApi::with_action_bridge(
1607 "owner",
1608 crate::ExtensionRegistry::new(),
1609 diag.clone(),
1610 master.clone(),
1611 );
1612 let other = crate::HostApi::with_action_bridge(
1613 "other",
1614 crate::ExtensionRegistry::new(),
1615 diag,
1616 master.clone(),
1617 );
1618 let vtable = owner.build_vtable();
1619 let claim = r#"{"ownsStdio":true}"#;
1620 {
1621 let _registration = crate::enter_current_api(&owner);
1622 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1623 }
1624 let other_vtable = other.build_vtable();
1625 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1626 let mut out = StbString::empty();
1627 assert_eq!(
1629 (other_vtable.runtime_action)(
1630 RuntimeActionId::StdioLifecycle as u32,
1631 finish,
1632 &mut out,
1633 other_vtable.user_data
1634 ),
1635 1
1636 );
1637 unsafe { (other_vtable.free_string)(out) };
1639 assert_eq!(
1640 (vtable.runtime_action)(
1641 RuntimeActionId::StdioLifecycle as u32,
1642 finish,
1643 &mut out,
1644 vtable.user_data
1645 ),
1646 0
1647 );
1648 unsafe { (vtable.free_string)(out) };
1650 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1651 {
1652 let _registration = crate::enter_current_api(&other);
1653 assert_ne!(
1654 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1655 0
1656 );
1657 }
1658 assert!(master
1659 .stdio_lifecycle()
1660 .validate()
1661 .unwrap_err()
1662 .contains("owner"));
1663 }
1664
1665 struct MockHost {
1670 prompt: String,
1671 saw: Mutex<Vec<RuntimeActionId>>,
1672 }
1673
1674 #[test]
1675 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1676 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1677 if std::env::var_os(CHILD).is_some() {
1678 let runtime = tokio::runtime::Builder::new_multi_thread()
1679 .worker_threads(1)
1680 .enable_all()
1681 .build()
1682 .unwrap();
1683 runtime.block_on(async {
1684 tokio::spawn(async {
1687 let host = Arc::new(MockHost {
1688 prompt: "worker callback".into(),
1689 saw: Mutex::new(Vec::new()),
1690 });
1691 let bridge = ActionBridge::new(Handle::current(), host);
1692 let mut out = StbString::empty();
1693 assert_eq!(
1694 trampoline_runtime_action(
1695 RuntimeActionId::GetSystemPrompt.into(),
1696 StbStringRef::from_str("{}"),
1697 &mut out,
1698 bridge.user_data(),
1699 ),
1700 0
1701 );
1702 let text = unsafe {
1704 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1705 out,
1706 Some(crate::host_free_string),
1707 )
1708 }
1709 .to_string_lossy();
1710 assert_eq!(
1711 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1712 "worker callback"
1713 );
1714 })
1715 .await
1716 .unwrap();
1717 });
1718 return;
1719 }
1720 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1723 .args([
1724 "--exact",
1725 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1726 "--nocapture",
1727 ])
1728 .env(CHILD, "1")
1729 .stdout(std::process::Stdio::piped())
1730 .stderr(std::process::Stdio::piped())
1731 .spawn()
1732 .unwrap();
1733 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1734 while child.try_wait().unwrap().is_none() {
1735 if std::time::Instant::now() >= deadline {
1736 let _ = child.kill();
1737 let _ = child.wait();
1738 panic!("runtime action blocked the worker needed by its own dispatch");
1739 }
1740 std::thread::sleep(std::time::Duration::from_millis(10));
1741 }
1742 let output = child.wait_with_output().unwrap();
1743 assert!(
1744 output.status.success(),
1745 "{}\n{}",
1746 String::from_utf8_lossy(&output.stdout),
1747 String::from_utf8_lossy(&output.stderr)
1748 );
1749 }
1750
1751 #[async_trait::async_trait]
1752 impl RuntimeActionHost for MockHost {
1753 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1754 unreachable!("not under test")
1755 }
1756 async fn send_user_message(
1757 &self,
1758 _: serde_json::Value,
1759 ) -> Result<serde_json::Value, String> {
1760 unreachable!("not under test")
1761 }
1762 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1763 unreachable!("not under test")
1764 }
1765 async fn set_session_name(
1766 &self,
1767 _: serde_json::Value,
1768 ) -> Result<serde_json::Value, String> {
1769 unreachable!("not under test")
1770 }
1771 async fn get_active_tools(
1772 &self,
1773 _: serde_json::Value,
1774 ) -> Result<serde_json::Value, String> {
1775 unreachable!("not under test")
1776 }
1777 async fn set_active_tools(
1778 &self,
1779 _: serde_json::Value,
1780 ) -> Result<serde_json::Value, String> {
1781 unreachable!("not under test")
1782 }
1783 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1784 unreachable!("not under test")
1785 }
1786 async fn get_thinking_level(
1787 &self,
1788 _: serde_json::Value,
1789 ) -> Result<serde_json::Value, String> {
1790 unreachable!("not under test")
1791 }
1792 async fn set_thinking_level(
1793 &self,
1794 _: serde_json::Value,
1795 ) -> Result<serde_json::Value, String> {
1796 unreachable!("not under test")
1797 }
1798 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1799 unreachable!("not under test")
1800 }
1801 async fn get_system_prompt(
1802 &self,
1803 _: serde_json::Value,
1804 ) -> Result<serde_json::Value, String> {
1805 self.saw
1806 .lock()
1807 .unwrap()
1808 .push(RuntimeActionId::GetSystemPrompt);
1809 Ok(serde_json::json!({ "prompt": self.prompt }))
1810 }
1811 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1812 unreachable!("not under test")
1813 }
1814 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1815 unreachable!("not under test")
1816 }
1817 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1818 unreachable!("not under test")
1819 }
1820 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1821 unreachable!("not under test")
1822 }
1823 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1824 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1825 Ok(serde_json::Value::Null)
1826 }
1827 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1828 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1829 Ok(serde_json::json!({ "value": true }))
1830 }
1831 }
1832
1833 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1837 async fn trampoline_round_trips_get_system_prompt() {
1838 let host = Arc::new(MockHost {
1839 prompt: "hello from host".to_string(),
1840 saw: Mutex::new(Vec::new()),
1841 });
1842 let host_for_assert = Arc::clone(&host);
1843 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1844 let runtime = tokio::runtime::Handle::current();
1845 let bridge = ActionBridge::new(runtime, host_dyn);
1846 let user_data = bridge.user_data();
1847
1848 let args_str = "{}";
1850 let args_ref = StbStringRef::from_str(args_str);
1851
1852 let mut out = StbString::empty();
1853 let rc = trampoline_runtime_action(
1854 RuntimeActionId::GetSystemPrompt.into(),
1855 args_ref,
1856 &mut out as *mut StbString,
1857 user_data,
1858 );
1859 assert_eq!(rc, 0, "success return code");
1860
1861 let json_text = unsafe {
1864 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1865 }
1866 .to_string_lossy();
1867 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1868 assert_eq!(parsed["prompt"], "hello from host");
1869
1870 let saw = host_for_assert.saw.lock().unwrap().clone();
1873 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1874 }
1875
1876 #[tokio::test]
1882 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1883 let host = Arc::new(MockHost {
1884 prompt: String::new(),
1885 saw: Mutex::new(Vec::new()),
1886 });
1887 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1888 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1889 let mailbox = bridge.editor_text_mailbox();
1890 let user_data = bridge.user_data();
1891
1892 let args_ref =
1893 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1894 let mut out = StbString::empty();
1895 let rc = trampoline_runtime_action(
1896 RuntimeActionId::SetEditorText.into(),
1897 args_ref,
1898 &mut out as *mut StbString,
1899 user_data,
1900 );
1901 assert_eq!(rc, 0, "success return code");
1902
1903 let parsed: serde_json::Value = serde_json::from_str(
1905 &unsafe {
1906 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1907 }
1908 .to_string_lossy(),
1909 )
1910 .expect("valid json");
1911 assert_eq!(parsed["ok"], serde_json::json!(true));
1912 assert_eq!(parsed["chars"], serde_json::json!(10));
1913
1914 let edit = mailbox.take_pending().expect("queued edit");
1916 assert_eq!(edit.text, "from voice");
1917 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1918 assert_eq!(edit.auto_send_ms, Some(2000));
1919 assert!(mailbox.take_pending().is_none());
1920 }
1921
1922 #[tokio::test]
1923 async fn trampoline_null_user_data_returns_minus_one() {
1924 let args_ref = StbStringRef::from_str("{}");
1925 let mut out = StbString::empty();
1926 let rc = trampoline_runtime_action(
1927 RuntimeActionId::GetSystemPrompt.into(),
1928 args_ref,
1929 &mut out as *mut StbString,
1930 std::ptr::null_mut(),
1931 );
1932 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1933 }
1934
1935 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1936 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1937 use std::sync::atomic::{AtomicUsize, Ordering};
1941 let reload_calls = Arc::new(AtomicUsize::new(0));
1942 let reload_calls_for_cb = Arc::clone(&reload_calls);
1943 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1944 Arc::new(move || {
1945 let c = Arc::clone(&reload_calls_for_cb);
1946 Box::pin(async move {
1947 c.fetch_add(1, Ordering::SeqCst);
1948 })
1949 });
1950 let host = Arc::new(MockHost {
1951 prompt: String::new(),
1952 saw: Mutex::new(Vec::new()),
1953 });
1954 let host_for_assert = Arc::clone(&host);
1955 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1956 let runtime = tokio::runtime::Handle::current();
1957 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1958 let user_data = bridge.user_data();
1959
1960 let args_ref = StbStringRef::from_str("{}");
1961 let mut out = StbString::empty();
1962 let rc = trampoline_runtime_action(
1963 RuntimeActionId::Reload.into(),
1964 args_ref,
1965 &mut out as *mut StbString,
1966 user_data,
1967 );
1968 assert_eq!(rc, 0);
1969 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1971 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1972 drop(unsafe {
1974 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1975 });
1976 }
1977
1978 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1983 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1984 let host = Arc::new(MockHost {
1985 prompt: String::new(),
1986 saw: Mutex::new(Vec::new()),
1987 });
1988 let host_for_assert = Arc::clone(&host);
1989 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1990 let runtime = tokio::runtime::Handle::current();
1991 let bridge = ActionBridge::new(runtime, host_dyn);
1992 let user_data = bridge.user_data();
1993
1994 bridge.invalidate();
1996 assert!(!bridge.is_active());
1997
1998 let args_ref = StbStringRef::from_str("{}");
1999 let mut out = StbString::empty();
2000 let rc = trampoline_runtime_action(
2001 RuntimeActionId::GetSystemPrompt.into(),
2002 args_ref,
2003 &mut out as *mut StbString,
2004 user_data,
2005 );
2006 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
2008 let json_text = unsafe {
2010 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2011 }
2012 .to_string_lossy();
2013 assert!(
2014 json_text.contains("stale"),
2015 "stale-bridge error payload: {json_text}"
2016 );
2017
2018 assert!(
2019 host_for_assert.saw.lock().unwrap().is_empty(),
2020 "host dispatch must NOT run on a stale bridge"
2021 );
2022 }
2023
2024 #[tokio::test]
2025 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
2026 let host = Arc::new(MockHost {
2027 prompt: String::new(),
2028 saw: Mutex::new(Vec::new()),
2029 });
2030 let host_for_assert = Arc::clone(&host);
2031 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2032 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2033 let user_data = bridge.user_data();
2034
2035 let mut out = StbString::empty();
2036 let rc = trampoline_runtime_action(
2037 0xFFFF_FFFE,
2038 StbStringRef::from_str("{}"),
2039 &mut out,
2040 user_data,
2041 );
2042
2043 assert_eq!(rc, 2, "unknown action id must be a protocol error");
2044 let payload: serde_json::Value = serde_json::from_str(
2045 &unsafe {
2046 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2047 }
2048 .to_string_lossy(),
2049 )
2050 .expect("structured error JSON");
2051 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
2052
2053 assert!(
2054 host_for_assert.saw.lock().unwrap().is_empty(),
2055 "unknown action id must not reach host dispatch"
2056 );
2057 }
2058
2059 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2060 async fn v2_trampoline_accepts_new_action() {
2061 let host = Arc::new(MockHost {
2062 prompt: "v2".to_string(),
2063 saw: Mutex::new(Vec::new()),
2064 });
2065 let host_for_assert = Arc::clone(&host);
2066 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2067 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2068 let user_data = bridge.user_data();
2069
2070 let mut v2_out = StbString::empty();
2071 assert_eq!(
2072 trampoline_runtime_action(
2073 RuntimeActionId::GetCliFlag.into(),
2074 StbStringRef::from_str(r#"{"name":"flag"}"#),
2075 &mut v2_out,
2076 user_data,
2077 ),
2078 0
2079 );
2080 drop(unsafe {
2082 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
2083 });
2084
2085 assert_eq!(
2086 *host_for_assert.saw.lock().unwrap(),
2087 vec![RuntimeActionId::GetCliFlag]
2088 );
2089 }
2090
2091 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2096 async fn reload_mailbox_signals_installed_receiver() {
2097 let mailbox = ReloadMailbox::new();
2098 assert!(matches!(mailbox.signal(), Err(())));
2100
2101 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2102 mailbox.install(tx);
2103 let cb = reload_callback_from_mailbox(mailbox.clone());
2104 cb().await;
2105 assert_eq!(
2106 rx.recv().await,
2107 Some(()),
2108 "installed receiver saw the signal"
2109 );
2110 mailbox.clear();
2111 }
2112
2113 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
2116 serde_json::json!({
2117 "op": "open",
2118 "requestId": id,
2119 "toolCallId": tool_call,
2120 "ui": {"kind": "selector", "questions": [
2121 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
2122 ]},
2123 })
2124 }
2125
2126 #[test]
2129 fn ui_dialog_open_requires_an_attached_ui() {
2130 let mailbox = UiDialogMailbox::new();
2131 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
2132 assert!(error.contains("interactive UI"), "got: {error}");
2133 }
2134
2135 #[test]
2136 fn ui_dialog_open_poll_answer_round_trip() {
2137 let mailbox = UiDialogMailbox::new();
2138 mailbox.attach();
2139
2140 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
2141 assert_eq!(opened["status"], "pending");
2142 assert_eq!(opened["toolCallId"], "c1");
2143
2144 let request = mailbox.take_pending().expect("request visible to TUI");
2145 assert_eq!(request.request_id, "r1");
2146 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
2147 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2149 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2150
2151 mailbox
2152 .respond("r1", serde_json::json!("7890"))
2153 .expect("respond");
2154 let answered = mailbox.poll("r1").unwrap();
2155 assert_eq!(answered["status"], "answered");
2156 assert_eq!(answered["answer"], "7890");
2157 assert!(mailbox.poll("r1").is_err());
2159 }
2160
2161 #[test]
2162 fn ui_dialog_cancel_is_reported_once() {
2163 let mailbox = UiDialogMailbox::new();
2164 mailbox.attach();
2165 mailbox.handle(open_args("r1", "c1")).unwrap();
2166 mailbox.cancel("r1").unwrap();
2167 let cancelled = mailbox.poll("r1").unwrap();
2168 assert_eq!(cancelled["status"], "cancelled");
2169 assert!(mailbox.poll("r1").is_err());
2170 }
2171
2172 #[test]
2173 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
2174 let mailbox = UiDialogMailbox::new();
2175 mailbox.attach();
2176 mailbox.handle(open_args("r1", "c1")).unwrap();
2177 mailbox.handle(open_args("r2", "c2")).unwrap();
2178 mailbox.cancel_tool_call("c1");
2179 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2180 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
2181 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
2182 assert_eq!(
2183 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
2184 "cancelled"
2185 );
2186 assert!(mailbox.take_pending().is_none());
2187 }
2188
2189 #[test]
2190 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
2191 let mailbox = UiDialogMailbox::new();
2192 mailbox.attach();
2193 mailbox.handle(open_args("r1", "c1")).unwrap();
2194 mailbox.handle(open_args("r2", "c2")).unwrap();
2195
2196 let first = mailbox.take_pending().unwrap().request_id;
2197 let second = mailbox.take_pending().unwrap().request_id;
2198 assert_ne!(first, second);
2199
2200 mailbox
2202 .respond(&second, serde_json::json!("second"))
2203 .unwrap();
2204 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
2205 let second_answer = mailbox.poll(&second).unwrap();
2206 assert_eq!(second_answer["answer"], "second");
2207
2208 mailbox.respond(&first, serde_json::json!("first")).unwrap();
2209 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
2210 }
2211
2212 #[test]
2213 fn ui_dialog_detach_cancels_outstanding() {
2214 let mailbox = UiDialogMailbox::new();
2215 mailbox.attach();
2216 mailbox.handle(open_args("r1", "c1")).unwrap();
2217 mailbox.detach();
2218 assert!(!mailbox.is_attached());
2219 assert_eq!(mailbox.pending_len(), 0);
2220 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
2222 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2223 }
2224
2225 #[test]
2226 fn ui_dialog_duplicate_open_does_not_reenqueue() {
2227 let mailbox = UiDialogMailbox::new();
2228 mailbox.attach();
2229 mailbox.handle(open_args("r1", "c1")).unwrap();
2230 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
2231 assert_eq!(again["status"], "pending");
2232 assert_eq!(mailbox.pending_len(), 1);
2233 mailbox.take_pending().unwrap();
2234 assert!(mailbox.take_pending().is_none());
2235 }
2236
2237 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2242 async fn list_commands_routes_to_the_host_and_defaults_to_empty() {
2243 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2244 prompt: String::new(),
2245 saw: Mutex::new(Vec::new()),
2246 });
2247 let bridge = ActionBridge::with_ui_dialog(
2248 tokio::runtime::Handle::current(),
2249 host,
2250 UiDialogMailbox::new(),
2251 );
2252 let user_data = bridge.user_data();
2253 let mut out = StbString::empty();
2254 let rc = trampoline_runtime_action(
2255 u32::from(RuntimeActionId::ListCommands),
2256 StbStringRef::from_str("{}"),
2257 &mut out,
2258 user_data,
2259 );
2260 assert_eq!(
2261 rc, 0,
2262 "ListCommands must not fail on a host with no registry"
2263 );
2264 let payload: serde_json::Value = serde_json::from_str(
2265 &unsafe {
2266 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2267 }
2268 .to_string_lossy(),
2269 )
2270 .expect("JSON");
2271 assert_eq!(
2272 payload
2273 .get("commands")
2274 .and_then(|c| c.as_array())
2275 .map(Vec::len),
2276 Some(0),
2277 "an empty registry is an empty list, not an error: {payload}"
2278 );
2279 }
2280
2281 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2285 async fn v2_dispatch_routes_ui_dialog_action_17() {
2286 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2287 prompt: String::new(),
2288 saw: Mutex::new(Vec::new()),
2289 });
2290 let bridge = ActionBridge::with_ui_dialog(
2291 tokio::runtime::Handle::current(),
2292 host,
2293 UiDialogMailbox::new(),
2294 );
2295 let user_data = bridge.user_data();
2296
2297 let mut out = StbString::empty();
2298 let rc = trampoline_runtime_action(
2299 17,
2300 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
2301 &mut out,
2302 user_data,
2303 );
2304 assert_eq!(rc, 1);
2305 let payload: serde_json::Value = serde_json::from_str(
2306 &unsafe {
2307 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2308 }
2309 .to_string_lossy(),
2310 )
2311 .expect("structured error JSON");
2312 assert!(
2313 payload["error"]
2314 .as_str()
2315 .is_some_and(|error| error.contains("interactive UI")),
2316 "unexpected payload: {payload}"
2317 );
2318
2319 let one_past_last = rpi_plugin_sdk::RuntimeActionId::MAX + 1;
2323 let mut out = StbString::empty();
2324 assert_eq!(
2325 trampoline_runtime_action(
2326 one_past_last,
2327 StbStringRef::from_str("{}"),
2328 &mut out,
2329 user_data
2330 ),
2331 2
2332 );
2333 drop(unsafe {
2335 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2336 });
2337 }
2338
2339 #[tokio::test(flavor = "current_thread")]
2340 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
2341 let host = Arc::new(MockHost {
2342 prompt: String::new(),
2343 saw: Mutex::new(Vec::new()),
2344 });
2345 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
2346 let a = root.for_plugin("a");
2347 let b = root.for_plugin("b");
2348 let mailbox = root.extension_status_mailbox();
2349 for plugin in [&a, &b] {
2350 let mut out = StbString::empty();
2351 assert_eq!(
2352 trampoline_runtime_action(
2353 22,
2354 StbStringRef::from_str(
2355 r#"{"op":"register","id":"same","slot":"footer.after"}"#
2356 ),
2357 &mut out,
2358 plugin.user_data()
2359 ),
2360 0
2361 );
2362 drop(unsafe {
2364 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2365 });
2366 }
2367 assert_eq!(mailbox.panels().len(), 2);
2368 drop(a);
2369 assert_eq!(mailbox.panels().len(), 1);
2370 root.invalidate();
2371 assert!(mailbox.panels().is_empty());
2372 }
2373
2374 #[test]
2380 fn relay_ui_dialog_runs_off_the_executor() {
2381 let mailbox = UiDialogMailbox::new();
2382 mailbox.attach();
2383 let out = dispatch_relay_ui_dialog(&mailbox, serde_json::json!({"op":"release"}))
2384 .expect("release is a supported op");
2385 assert_eq!(out["requeued"], serde_json::json!(0));
2386 }
2387
2388 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2391 async fn v2_dispatch_routes_set_status_action_18() {
2392 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2393 prompt: String::new(),
2394 saw: Mutex::new(Vec::new()),
2395 });
2396 let status = crate::status::ExtensionStatusMailbox::new();
2397 let bridge = ActionBridge::with_reload_and_ui(
2398 tokio::runtime::Handle::current(),
2399 host,
2400 Arc::new(|| Box::pin(async {})),
2401 UiDialogMailbox::new(),
2402 status.clone(),
2403 crate::editor_text::EditorTextMailbox::new(),
2404 );
2405 let user_data = bridge.user_data();
2406
2407 let mut out = StbString::empty();
2408 let rc = trampoline_runtime_action(
2409 18,
2410 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2411 &mut out,
2412 user_data,
2413 );
2414 assert_eq!(rc, 0);
2415 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2416 drop(unsafe {
2418 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2419 });
2420
2421 let mut out = StbString::empty();
2423 assert_eq!(
2424 trampoline_runtime_action(
2425 18,
2426 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2427 &mut out,
2428 user_data
2429 ),
2430 0
2431 );
2432 assert_eq!(status.text(), "");
2433 drop(unsafe {
2435 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2436 });
2437
2438 let mut out = StbString::empty();
2440 assert_eq!(
2441 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2442 1
2443 );
2444 drop(unsafe {
2446 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2447 });
2448 }
2449 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2456 async fn trampoline_ceiling_matches_the_highest_defined_action() {
2457 let highest = RuntimeActionId::MAX;
2458 assert_eq!(
2459 highest,
2460 RuntimeActionId::ALL
2461 .iter()
2462 .copied()
2463 .map(u32::from)
2464 .max()
2465 .unwrap(),
2466 "RuntimeActionId::MAX must be the maximum of RuntimeActionId::ALL"
2467 );
2468 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2469 prompt: String::new(),
2470 saw: Mutex::new(Vec::new()),
2471 });
2472 let bridge = ActionBridge::with_ui_dialog(
2473 tokio::runtime::Handle::current(),
2474 host,
2475 UiDialogMailbox::new(),
2476 );
2477 let mut out = StbString::empty();
2478 let rc = trampoline_runtime_action(
2479 highest,
2480 StbStringRef::from_str("{}"),
2481 &mut out,
2482 bridge.user_data(),
2483 );
2484 assert_ne!(
2485 rc, 2,
2486 "the highest defined action id ({highest}) must not be rejected as unknown — \
2487 the trampoline ceiling is stale"
2488 );
2489 if rc == 0 {
2490 drop(unsafe {
2492 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2493 });
2494 }
2495 }
2496}
2497
2498impl Drop for ActionBridge {
2499 fn drop(&mut self) {
2500 bridges()
2501 .lock()
2502 .unwrap_or_else(|e| e.into_inner())
2503 .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2504 if self.invocation.is_some() {
2505 return;
2506 }
2507 if let Some((owner, _)) = &self.plugin_identity {
2508 self.ext_status.remove_owner(*owner);
2509 self.host.cancel_child_agents(*owner);
2510 self.host.cancel_side_questions(*owner);
2511 }
2512 }
2513}
2514
2515#[cfg(test)]
2516mod runtime_tests;
2517
2518#[cfg(test)]
2519mod relay_tests {
2520 use super::*;
2521
2522 fn request(id: &str) -> UiDialogRequest {
2523 UiDialogRequest {
2524 request_id: id.to_string(),
2525 tool_call_id: Some(format!("tool-{id}")),
2526 ui: serde_json::json!({"question": "ok?"}),
2527 raw: serde_json::json!({}),
2528 }
2529 }
2530
2531 #[test]
2535 fn reserved_requests_are_invisible_to_the_tui() {
2536 let mailbox = UiDialogMailbox::new();
2537 mailbox.attach();
2538 let reserved = format!("{PLUGIN_RESERVED_PREFIX}gate-1");
2539 mailbox
2540 .handle(serde_json::json!({
2541 "op": "open",
2542 "requestId": reserved,
2543 "toolCallId": "t1",
2544 "ui": {}
2545 }))
2546 .unwrap();
2547 mailbox
2548 .handle(serde_json::json!({
2549 "op": "open",
2550 "requestId": "plain-1",
2551 "toolCallId": "t2",
2552 "ui": {}
2553 }))
2554 .unwrap();
2555
2556 let taken = mailbox
2558 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2559 .expect("the unreserved request must reach the TUI");
2560 assert_eq!(taken.request_id, "plain-1");
2561
2562 let own = mailbox
2564 .take_pending_filtered(|r| mailbox.is_reserved(&r.request_id))
2565 .expect("the plugin must still see its reserved request");
2566 assert_eq!(own.request_id, reserved);
2567 }
2568
2569 #[test]
2571 fn release_or_detach_clears_takeover_and_requeues_unanswered() {
2572 let mailbox = UiDialogMailbox::new();
2573 mailbox.attach();
2574 mailbox
2575 .handle(serde_json::json!({
2576 "op": "open", "requestId": "plain-r", "toolCallId": "t", "ui": {}
2577 }))
2578 .unwrap();
2579
2580 mailbox.set_external_takeover(true);
2582 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2583 assert_eq!(mailbox.release_takeover(), 1);
2584 assert!(
2585 !mailbox.external_takeover(),
2586 "release must hand delivery back to the TUI"
2587 );
2588 assert_eq!(
2590 mailbox
2591 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2592 .map(|r| r.request_id),
2593 Some("plain-r".to_string())
2594 );
2595
2596 mailbox.set_external_takeover(true);
2598 mailbox.detach();
2599 assert!(!mailbox.external_takeover(), "detach must clear takeover");
2600 }
2601
2602 #[test]
2605 fn tui_take_is_atomic_against_takeover() {
2606 let mailbox = UiDialogMailbox::new();
2607 mailbox.attach();
2608 mailbox.set_external_takeover(true);
2609 mailbox
2610 .handle(serde_json::json!({
2611 "op": "open", "requestId": "plain-a", "toolCallId": "t", "ui": {}
2612 }))
2613 .unwrap();
2614 assert!(
2615 mailbox.take_pending_unless_external(|_| true).is_none(),
2616 "the TUI must stand down while a relay owns delivery"
2617 );
2618 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2620 }
2621
2622 #[test]
2625 fn relay_take_returns_unreserved_and_keeps_reserved() {
2626 let mailbox = UiDialogMailbox::new();
2627 mailbox.attach();
2628 mailbox
2629 .handle(serde_json::json!({
2630 "op": "open",
2631 "requestId": format!("{PLUGIN_RESERVED_PREFIX}keep"),
2632 "toolCallId": "t1",
2633 "ui": {}
2634 }))
2635 .unwrap();
2636 mailbox
2637 .handle(serde_json::json!({
2638 "op": "open",
2639 "requestId": "relay-1",
2640 "toolCallId": "t2",
2641 "ui": {}
2642 }))
2643 .unwrap();
2644
2645 let taken = mailbox.take_all_unreserved();
2646 assert_eq!(taken.len(), 1);
2647 assert_eq!(taken[0].request_id, "relay-1");
2648 assert_eq!(mailbox.pending_len(), 1);
2650 }
2651
2652 #[test]
2655 fn a_relayed_request_can_still_be_answered() {
2656 let mailbox = UiDialogMailbox::new();
2657 mailbox.attach();
2658 mailbox
2659 .handle(serde_json::json!({
2660 "op": "open",
2661 "requestId": "relay-1",
2662 "toolCallId": "t1",
2663 "ui": {}
2664 }))
2665 .unwrap();
2666 let taken = mailbox.take_all_unreserved();
2667 assert_eq!(taken.len(), 1);
2668
2669 mailbox
2670 .handle(serde_json::json!({
2671 "op": "respond",
2672 "requestId": "relay-1",
2673 "answer": {"ok": true}
2674 }))
2675 .unwrap();
2676 assert_eq!(
2678 mailbox
2679 .take_pending_filtered(|_| true)
2680 .map(|r| r.request_id),
2681 None,
2682 "an answered request must not resurface"
2683 );
2684 }
2685
2686 #[test]
2689 fn take_pending_still_takes_reserved_entries() {
2690 let mailbox = UiDialogMailbox::new();
2691 mailbox.attach();
2692 let reserved = format!("{PLUGIN_RESERVED_PREFIX}x");
2693 mailbox
2694 .handle(serde_json::json!({
2695 "op": "open",
2696 "requestId": reserved,
2697 "toolCallId": "t1",
2698 "ui": {}
2699 }))
2700 .unwrap();
2701 assert_eq!(
2702 mailbox.take_pending().map(|r| r.request_id),
2703 Some(format!("{PLUGIN_RESERVED_PREFIX}x"))
2704 );
2705 }
2706
2707 #[test]
2713 fn external_takeover_makes_the_tui_stand_down() {
2714 let mailbox = UiDialogMailbox::new();
2715 mailbox.attach();
2716 mailbox
2717 .handle(serde_json::json!({
2718 "op": "open",
2719 "requestId": "plain-1",
2720 "toolCallId": "t1",
2721 "ui": {}
2722 }))
2723 .unwrap();
2724
2725 assert!(!mailbox.external_takeover());
2726 mailbox.set_external_takeover(true);
2727 assert!(mailbox.external_takeover());
2728
2729 let tui_take = |m: &UiDialogMailbox| {
2731 if m.external_takeover() {
2732 return None;
2733 }
2734 m.take_pending_filtered(|r| !m.is_reserved(&r.request_id))
2735 };
2736 assert!(
2737 tui_take(&mailbox).is_none(),
2738 "the TUI must not steal a request an external UI owns"
2739 );
2740 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2742 }
2743
2744 #[test]
2746 fn clearing_takeover_returns_dialogs_to_the_tui() {
2747 let mailbox = UiDialogMailbox::new();
2748 mailbox.attach();
2749 mailbox.set_external_takeover(true);
2750 mailbox.set_external_takeover(false);
2751 mailbox
2752 .handle(serde_json::json!({
2753 "op": "open",
2754 "requestId": "plain-2",
2755 "toolCallId": "t2",
2756 "ui": {}
2757 }))
2758 .unwrap();
2759 assert_eq!(
2760 mailbox
2761 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2762 .map(|r| r.request_id),
2763 Some("plain-2".to_string())
2764 );
2765 }
2766
2767 #[test]
2768 fn request_helper_matches_the_prefix_rule() {
2769 let mailbox = UiDialogMailbox::new();
2770 mailbox.attach();
2771 assert!(mailbox.is_reserved(&request(PLUGIN_RESERVED_PREFIX).request_id));
2772 assert!(!mailbox.is_reserved(&request("plain").request_id));
2773 }
2774}