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 get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
657 Err("CLI flag lookup is not configured".to_string())
658 }
659
660 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
664 Err("ask_user requires an interactive UI".to_string())
665 }
666
667 async fn list_commands(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
678 Ok(serde_json::json!({ "commands": [] }))
679 }
680
681 fn publish_registry(&self, _snapshot: std::sync::Arc<crate::registry::RegistrySnapshot>) {}
692
693 fn publish_registry_opt(
699 &self,
700 snapshot: Option<std::sync::Arc<crate::registry::RegistrySnapshot>>,
701 ) {
702 if let Some(snapshot) = snapshot {
703 self.publish_registry(snapshot);
704 }
705 }
706
707 async fn abort(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
715 Ok(serde_json::json!({
716 "aborted": false,
717 "steer": [],
718 "followUp": [],
719 }))
720 }
721
722 fn stop_round(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
732 Ok(serde_json::json!({ "requested": true }))
733 }
734
735 async fn get_execution_context(
737 &self,
738 _args: serde_json::Value,
739 ) -> Result<serde_json::Value, String> {
740 Err("execution context is not supported by this host".into())
741 }
742
743 async fn child_agent(
745 &self,
746 _owner: u64,
747 _args: serde_json::Value,
748 ) -> Result<serde_json::Value, String> {
749 Err("ChildAgent is not supported by this host".into())
750 }
751
752 async fn get_session_branch(
762 &self,
763 _args: serde_json::Value,
764 ) -> Result<serde_json::Value, String> {
765 Err("session branch lookup is not configured".to_string())
766 }
767}
768
769pub struct ActionBridge {
796 invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
798 execution_context: Option<serde_json::Value>,
799 tool_program: Option<Arc<rpi_agent::tool_program::ToolProgram>>,
800 stdio: crate::StdioLifecycle,
801 plugin_identity: Option<(u64, String)>,
802 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
803 pub(crate) runtime: Handle,
805 pub(crate) host: Arc<dyn RuntimeActionHost>,
807 pub(crate) reload:
809 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
810 ui_dialog: UiDialogMailbox,
813 ext_status: crate::status::ExtensionStatusMailbox,
817 editor_text: crate::editor_text::EditorTextMailbox,
821 active: Arc<AtomicBool>,
824}
825
826impl ActionBridge {
827 pub fn user_data(self: &Arc<Self>) -> *mut c_void {
829 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
830 let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
831 if let Some((&id, _)) = registry
832 .iter()
833 .find(|(_, entry)| entry.bridge.as_ptr() == Arc::as_ptr(self))
834 {
835 return id as *mut c_void;
836 }
837 let mut id = NEXT.load(Ordering::Relaxed);
838 loop {
839 let next = id.checked_add(1).expect("runtime handle space exhausted");
840 match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
841 Ok(_) => break,
842 Err(current) => id = current,
843 }
844 }
845 registry.insert(
846 id,
847 RegistryEntry {
848 bridge: Arc::downgrade(self),
849 active: Arc::downgrade(&self.active),
850 },
851 );
852 id as *mut c_void
853 }
854 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
855 bridges()
856 .lock()
857 .unwrap_or_else(|e| e.into_inner())
858 .get(&id)
859 .and_then(|entry| entry.bridge.upgrade())
860 }
861 pub(crate) fn for_invocation(
862 &self,
863 child: bool,
864 signal: tokio_util::sync::CancellationToken,
865 ) -> Arc<Self> {
866 let tool_program = rpi_agent::tool_program::current();
867 if let Some(program) = &tool_program {
868 program.bind_signal(signal.clone());
869 }
870 Arc::new(Self {
871 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
872 execution_context: rpi_agent::tool_execution_scope::execution_context(),
873 tool_program,
874 stdio: self.stdio.clone(),
875 plugin_identity: self.plugin_identity.clone(),
876 component_owners: self.component_owners.clone(),
877 runtime: self.runtime.clone(),
878 host: self.host.clone(),
879 reload: self.reload.clone(),
880 ui_dialog: self.ui_dialog.clone(),
881 ext_status: self.ext_status.clone(),
882 editor_text: self.editor_text.clone(),
883 active: self.active.clone(),
884 })
885 }
886 pub(crate) fn resolve_result_reference(
889 &self,
890 id: &str,
891 ) -> Result<(rpi_agent::AgentToolResult, bool), String> {
892 if !self.is_active()
893 || !self
894 .invocation
895 .as_ref()
896 .is_some_and(|(_, _, signal)| !signal.is_cancelled())
897 {
898 return Err("result reference requires a live invocation bridge".into());
899 }
900 self.tool_program
901 .as_ref()
902 .ok_or("result reference requires an effective ToolProgram")?
903 .resolve_result_reference(id)
904 }
905 pub(crate) fn resolve_content_parts(
907 &self,
908 parts: Vec<rpi_agent::tool_program::ToolProgramContentPart>,
909 ) -> Result<Vec<rpi_agent::TextContentOrImage>, String> {
910 if !self.is_active()
911 || !self
912 .invocation
913 .as_ref()
914 .is_some_and(|(_, _, signal)| !signal.is_cancelled())
915 {
916 return Err("content parts require a live invocation bridge".into());
917 }
918 self.tool_program
919 .as_ref()
920 .ok_or("content parts require an effective ToolProgram")?
921 .resolve_content_parts(parts)
922 }
923 pub(crate) fn invocation_key(&self) -> Option<&str> {
924 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
925 }
926 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
927 InvocationGuard {
928 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
929 _thread_bound: std::marker::PhantomData,
930 }
931 }
932 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
935 Arc::new(Self {
936 invocation: None,
937 tool_program: None,
938 execution_context: None,
939 stdio: crate::StdioLifecycle::default(),
940 plugin_identity: None,
941 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
942 runtime,
943 host,
944 reload: None,
945 ui_dialog: UiDialogMailbox::new(),
946 ext_status: crate::status::ExtensionStatusMailbox::new(),
947 editor_text: crate::editor_text::EditorTextMailbox::new(),
948 active: Arc::new(AtomicBool::new(true)),
949 })
950 }
951
952 pub fn with_reload(
955 runtime: Handle,
956 host: Arc<dyn RuntimeActionHost>,
957 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
958 ) -> Arc<Self> {
959 Self::with_reload_and_ui(
960 runtime,
961 host,
962 reload,
963 UiDialogMailbox::new(),
964 crate::status::ExtensionStatusMailbox::new(),
965 crate::editor_text::EditorTextMailbox::new(),
966 )
967 }
968
969 pub fn with_ui_dialog(
971 runtime: Handle,
972 host: Arc<dyn RuntimeActionHost>,
973 ui_dialog: UiDialogMailbox,
974 ) -> Arc<Self> {
975 Arc::new(Self {
976 invocation: None,
977 tool_program: None,
978 execution_context: None,
979 stdio: crate::StdioLifecycle::default(),
980 plugin_identity: None,
981 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
982 runtime,
983 host,
984 reload: None,
985 ui_dialog,
986 ext_status: crate::status::ExtensionStatusMailbox::new(),
987 editor_text: crate::editor_text::EditorTextMailbox::new(),
988 active: Arc::new(AtomicBool::new(true)),
989 })
990 }
991
992 pub fn with_reload_and_ui(
994 runtime: Handle,
995 host: Arc<dyn RuntimeActionHost>,
996 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
997 ui_dialog: UiDialogMailbox,
998 ext_status: crate::status::ExtensionStatusMailbox,
999 editor_text: crate::editor_text::EditorTextMailbox,
1000 ) -> Arc<Self> {
1001 Arc::new(Self {
1002 invocation: None,
1003 tool_program: None,
1004 execution_context: None,
1005 stdio: crate::StdioLifecycle::default(),
1006 plugin_identity: None,
1007 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
1008 runtime,
1009 host,
1010 reload: Some(reload),
1011 ui_dialog,
1012 ext_status,
1013 editor_text,
1014 active: Arc::new(AtomicBool::new(true)),
1015 })
1016 }
1017
1018 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
1019 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1020 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1021 self.component_owners
1022 .lock()
1023 .unwrap_or_else(|e| e.into_inner())
1024 .push(owner);
1025 Arc::new(Self {
1026 invocation: None,
1027 tool_program: None,
1028 execution_context: None,
1029 component_owners: self.component_owners.clone(),
1030 stdio: self.stdio.clone(),
1031 plugin_identity: Some((owner, name.to_owned())),
1032 runtime: self.runtime.clone(),
1033 host: self.host.clone(),
1034 reload: self.reload.clone(),
1035 ui_dialog: self.ui_dialog.clone(),
1036 ext_status: self.ext_status.clone(),
1037 editor_text: self.editor_text.clone(),
1038 active: self.active.clone(),
1039 })
1040 }
1041
1042 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
1043 self.stdio.clone()
1044 }
1045
1046 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
1047 let (id, name) = self
1048 .plugin_identity
1049 .as_ref()
1050 .ok_or("missing plugin identity")?;
1051 self.stdio.claim(*id, name)
1052 }
1053
1054 pub(crate) fn release_stdio_claim(&self) {
1055 if let Some((id, _)) = &self.plugin_identity {
1056 self.stdio.release_failed_registration(*id);
1057 self.ext_status.remove_owner(*id);
1058 }
1059 }
1060
1061 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
1063 self.ui_dialog.clone()
1064 }
1065
1066 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
1068 self.ext_status.clone()
1069 }
1070
1071 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
1073 self.editor_text.clone()
1074 }
1075
1076 pub fn invalidate(&self) {
1081 let owners = {
1082 let owners = self
1083 .component_owners
1084 .lock()
1085 .unwrap_or_else(|e| e.into_inner());
1086 self.active.store(false, Ordering::SeqCst);
1087 for owner in owners.iter() {
1088 self.ext_status.remove_owner(*owner);
1089 }
1090 owners.clone()
1091 };
1092 let live = self.bridges_for_owner();
1094 for bridge in live {
1095 if let Some(program) = &bridge.tool_program {
1096 program.cancel_all();
1097 }
1098 }
1099 for owner in owners {
1102 self.host.cancel_child_agents(owner);
1103 self.host.cancel_side_questions(owner);
1104 }
1105 }
1106
1107 fn bridges_for_owner(&self) -> Vec<Arc<Self>> {
1108 let active = Arc::downgrade(&self.active);
1109 bridges()
1115 .lock()
1116 .unwrap_or_else(|e| e.into_inner())
1117 .values()
1118 .filter(|entry| entry.active.ptr_eq(&active))
1119 .filter_map(|entry| entry.bridge.upgrade())
1120 .collect()
1121 }
1122
1123 pub fn is_active(&self) -> bool {
1125 self.active.load(Ordering::SeqCst)
1126 }
1127
1128 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
1135 Arc::clone(&self.host)
1136 }
1137}
1138
1139#[derive(Clone)]
1152pub struct ReloadMailbox {
1153 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
1154}
1155
1156impl Default for ReloadMailbox {
1157 fn default() -> Self {
1158 Self {
1159 tx: Arc::new(std::sync::Mutex::new(None)),
1160 }
1161 }
1162}
1163
1164impl ReloadMailbox {
1165 pub fn new() -> Self {
1167 Self::default()
1168 }
1169
1170 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
1174 *self.tx.lock().unwrap() = Some(tx);
1175 }
1176
1177 pub fn signal(&self) -> Result<(), ()> {
1182 let g = self.tx.lock().unwrap();
1183 match &*g {
1184 Some(tx) => {
1185 let _ = tx.send(());
1186 Ok(())
1187 }
1188 None => Err(()),
1189 }
1190 }
1191
1192 pub fn clear(&self) {
1194 *self.tx.lock().unwrap() = None;
1195 }
1196}
1197
1198pub fn reload_callback_from_mailbox(
1205 mailbox: ReloadMailbox,
1206) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
1207 Arc::new(move || {
1208 let m = mailbox.clone();
1209 Box::pin(async move {
1210 let _ = m.signal();
1211 })
1212 })
1213}
1214
1215fn dispatch_relay_ui_dialog(
1230 ui_dialog: &UiDialogMailbox,
1231 args: serde_json::Value,
1232) -> Result<serde_json::Value, String> {
1233 let op = args
1234 .get("op")
1235 .and_then(serde_json::Value::as_str)
1236 .unwrap_or("take");
1237 match op {
1238 "take" => {
1239 ui_dialog.set_external_takeover(true);
1243 let requests: Vec<serde_json::Value> = ui_dialog
1244 .take_all_unreserved()
1245 .into_iter()
1246 .map(|request| {
1247 serde_json::json!({
1248 "requestId": request.request_id,
1249 "toolCallId": request.tool_call_id,
1250 "ui": request.ui,
1251 })
1252 })
1253 .collect();
1254 Ok(serde_json::json!({ "requests": requests }))
1255 }
1256 "release" => {
1259 let requeued = ui_dialog.release_takeover();
1260 Ok(serde_json::json!({ "requeued": requeued }))
1261 }
1262 "respond" | "cancel" => ui_dialog.handle(args),
1264 other => Err(format!("RelayUiDialog does not support op `{other}`")),
1265 }
1266}
1267
1268async fn dispatch(
1269 host: &Arc<dyn RuntimeActionHost>,
1270 ui_dialog: &UiDialogMailbox,
1271 ext_status: &crate::status::ExtensionStatusMailbox,
1272 editor_text: &crate::editor_text::EditorTextMailbox,
1273 action: RuntimeActionId,
1274 args: serde_json::Value,
1275) -> Result<serde_json::Value, String> {
1276 match action {
1277 RuntimeActionId::SendMessage => host.send_message(args).await,
1278 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
1279 RuntimeActionId::AppendEntry => host.append_entry(args).await,
1280 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
1281 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
1282 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
1283 RuntimeActionId::SetModel => host.set_model(args).await,
1284 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
1285 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
1286 RuntimeActionId::Compact => host.compact(args).await,
1287 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
1288 RuntimeActionId::NewSession => host.new_session(args).await,
1289 RuntimeActionId::Fork => host.fork(args).await,
1290 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
1291 RuntimeActionId::SwitchSession => host.switch_session(args).await,
1292 RuntimeActionId::Reload => host.reload(args).await,
1293 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
1294 RuntimeActionId::UiDialog => ui_dialog.handle(args),
1295 RuntimeActionId::SetStatus => ext_status.handle(args),
1298 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
1299 RuntimeActionId::SetEditorText => editor_text.handle(args),
1302 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
1303 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
1304 RuntimeActionId::ToolProgram => Err("ToolProgram requires a live invocation bridge".into()),
1305 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
1306 RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
1307 RuntimeActionId::ListCommands => host.list_commands(args).await,
1308 RuntimeActionId::Abort => host.abort(args).await,
1309 RuntimeActionId::RelayUiDialog => dispatch_relay_ui_dialog(ui_dialog, args),
1313 RuntimeActionId::StopRound => host.stop_round(args),
1314 RuntimeActionId::StdioLifecycle => {
1315 Err("stdio lifecycle requires a plugin context".to_owned())
1316 }
1317 }
1318}
1319
1320pub extern "C" fn trampoline_runtime_action(
1344 action_id: u32,
1345 args_json: StbStringRef,
1346 out: *mut StbString,
1347 user_data: *mut c_void,
1348) -> i32 {
1349 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1350 run_action(
1351 action_id,
1352 rpi_plugin_sdk::RuntimeActionId::MAX,
1354 args_json,
1355 out,
1356 user_data,
1357 )
1358 }));
1359 match outcome {
1360 Ok(rc) => rc,
1361 Err(_) => {
1362 tracing::error!("runtime_action trampoline panicked — reporting host error");
1366 if !out.is_null() {
1367 unsafe {
1368 *out = StbString::from_string(
1369 r#"{"error":"runtime_action panicked"}"#.to_string(),
1370 );
1371 }
1372 }
1373 1
1374 }
1375 }
1376}
1377
1378fn run_action(
1380 action_id: u32,
1381 max_action_id: u32,
1382 args_json: StbStringRef,
1383 out: *mut StbString,
1384 user_data: *mut c_void,
1385) -> i32 {
1386 let action = match RuntimeActionId::try_from(action_id) {
1390 Ok(action) if action_id <= max_action_id => action,
1391 Ok(_) | Err(_) => {
1392 if !out.is_null() {
1393 let json = serde_json::json!({
1394 "error": format!("unknown runtime action id {action_id}")
1395 })
1396 .to_string();
1397 unsafe {
1401 *out = StbString::from_string(json);
1402 }
1403 }
1404 return 2;
1405 }
1406 };
1407
1408 if user_data.is_null() {
1409 return -1;
1410 }
1411 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1414 let invocation_thread = invocation.is_some();
1415 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1416 let Some(bridge) = bridge else {
1417 if !out.is_null() {
1418 unsafe {
1420 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1421 }
1422 }
1423 return 1;
1424 };
1425
1426 if !bridge.is_active() {
1434 if out.is_null() {
1435 return 1;
1436 }
1437 let json = serde_json::json!({
1438 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1439 })
1440 .to_string();
1441 unsafe {
1442 *out = StbString::from_string(json);
1443 }
1444 return 1;
1445 }
1446
1447 let args_str = unsafe { args_json.as_str() };
1450 let mut args: serde_json::Value = if args_str.is_empty() {
1451 serde_json::Value::Object(serde_json::Map::new())
1452 } else {
1453 serde_json::from_str(args_str)
1454 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1455 };
1456
1457 let runtime_key = args
1458 .as_object_mut()
1459 .and_then(|args| args.remove("__rpiRuntimeKey"));
1460
1461 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1466 let host = Arc::clone(&bridge.host);
1470 let ui_dialog = bridge.ui_dialog.clone();
1471 let ext_status = bridge.ext_status.clone();
1472 let editor_text = bridge.editor_text.clone();
1473 let reload_cb = bridge.reload.clone();
1474 let execution_context = bridge.execution_context.clone();
1475 if action == RuntimeActionId::StdioLifecycle {
1476 let result = bridge.stdio.handle(
1477 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1478 args,
1479 );
1480 let _ = tx.send(result);
1481 } else if action == RuntimeActionId::UiComponent {
1482 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1483 let _owners = bridge
1484 .component_owners
1485 .lock()
1486 .unwrap_or_else(|e| e.into_inner());
1487 let result = if bridge.is_active() {
1488 ext_status.component(owner, args)
1489 } else {
1490 Err("stale plugin bridge".into())
1491 };
1492 let _ = tx.send(result);
1493 } else if action == RuntimeActionId::SideQuestion {
1494 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1495 if owner == 0 || bridge.invocation.is_some() {
1496 let _ = tx.send(Err(
1497 "SideQuestion requires a plugin command, not a tool invocation".into(),
1498 ));
1499 } else {
1500 let weak = Arc::downgrade(&bridge);
1503 let sink: SideQuestionSink = Arc::new(move |args| {
1504 let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1505 let _owners = bridge
1506 .component_owners
1507 .lock()
1508 .unwrap_or_else(|e| e.into_inner());
1509 if !bridge.is_active() {
1510 return Err("stale side question bridge".into());
1511 }
1512 if args["op"] == "check" {
1513 return Ok(serde_json::json!({"ok":true}));
1514 }
1515 bridge.ext_status.component(owner, args)
1516 });
1517 bridge.runtime.spawn(async move {
1518 let _ = tx.send(host.side_question(owner, args, sink).await);
1519 });
1520 }
1521 } else if action == RuntimeActionId::GetActiveTools && bridge.tool_program.is_some() {
1522 let tools = bridge
1523 .tool_program
1524 .as_ref()
1525 .expect("checked")
1526 .active_tool_names();
1527 let _ = tx.send(Ok(serde_json::json!({"tools":tools})));
1528 } else if action == RuntimeActionId::SetActiveTools && bridge.execution_context.is_some() {
1529 let _ = tx.send(Err(
1530 "cannot replace active tools from a narrowed execution".into()
1531 ));
1532 } else if action == RuntimeActionId::ToolProgram {
1533 let cleanup = matches!(
1534 args.get("op").and_then(serde_json::Value::as_str),
1535 Some("poll" | "cancel" | "dispose" | "release_result")
1536 );
1537 let authenticated = bridge.invocation.as_ref().is_some_and(|(_, key, signal)| {
1538 (cleanup || !signal.is_cancelled())
1539 && (invocation_thread
1540 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1541 == Some(key.as_str()))
1542 });
1543 if let Some(capability) = bridge.tool_program.clone().filter(|_| authenticated) {
1544 let signal = bridge.invocation.as_ref().expect("authenticated").2.clone();
1545 bridge.runtime.spawn(async move {
1546 if signal.is_cancelled() {
1547 capability.cancel_all();
1548 if !cleanup {
1549 let _ = tx.send(Err("parent invocation is no longer active".into()));
1550 return;
1551 }
1552 }
1553 let _ = tx.send(host.tool_program(capability, args).await);
1554 });
1555 } else {
1556 let _ = tx.send(Err(
1557 "ToolProgram requires an authenticated executing invocation".into(),
1558 ));
1559 }
1560 } else if action == RuntimeActionId::ChildAgent {
1561 let cleanup = matches!(
1566 args.get("op").and_then(serde_json::Value::as_str),
1567 Some("poll" | "cancel" | "dispose")
1568 );
1569 let allowed = bridge
1570 .invocation
1571 .as_ref()
1572 .is_some_and(|(child, key, signal)| {
1573 !child
1574 && (cleanup || !signal.is_cancelled())
1575 && (invocation_thread
1576 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1577 == Some(key.as_str()))
1578 });
1579 if !allowed {
1580 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1581 } else {
1582 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1583 let signal = bridge
1584 .invocation
1585 .as_ref()
1586 .expect("authorized invocation")
1587 .2
1588 .clone();
1589 bridge.runtime.spawn(async move {
1590 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1591 signal,
1592 host.child_agent(owner, args),
1593 )
1594 .await;
1595 let _ = tx.send(result);
1596 });
1597 }
1598 } else if action == RuntimeActionId::SetStatus {
1599 std::thread::spawn(move || {
1604 let _ = tx.send(ext_status.handle(args));
1605 });
1606 } else if action == RuntimeActionId::SetEditorText {
1607 std::thread::spawn(move || {
1610 let _ = tx.send(editor_text.handle(args));
1611 });
1612 } else if action == RuntimeActionId::RelayUiDialog {
1613 std::thread::spawn(move || {
1619 let _ = tx.send(dispatch_relay_ui_dialog(&ui_dialog, args));
1620 });
1621 } else if action == RuntimeActionId::UiDialog {
1622 std::thread::spawn(move || {
1628 let _ = tx.send(ui_dialog.handle(args));
1629 });
1630 } else {
1631 bridge.runtime.spawn(async move {
1632 let mut r = if action == RuntimeActionId::Reload {
1633 if let Some(cb) = reload_cb {
1634 cb().await;
1635 Ok(serde_json::Value::Null)
1636 } else {
1637 host.reload(args).await
1638 }
1639 } else {
1640 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1641 };
1642 if action == RuntimeActionId::GetExecutionContext {
1643 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1644 (&mut r, execution_context)
1645 {
1646 if let Some(object) = context.as_object_mut() {
1647 object.extend(fields);
1648 }
1649 }
1650 }
1651 let _ = tx.send(r);
1654 });
1655 }
1656
1657 let received = if Handle::try_current()
1663 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1664 {
1665 tokio::task::block_in_place(|| rx.recv())
1666 } else {
1667 rx.recv()
1668 };
1669 let result = match received {
1670 Ok(r) => r,
1671 Err(_) => {
1672 return -2;
1677 }
1678 };
1679
1680 if out.is_null() {
1683 return match result {
1685 Ok(_) => 0,
1686 Err(_) => 1,
1687 };
1688 }
1689
1690 let (rc, payload) = match result {
1691 Ok(value) => {
1692 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1693 (0, json)
1694 }
1695 Err(msg) => {
1696 let json = serde_json::json!({ "error": msg }).to_string();
1697 (1, json)
1698 }
1699 };
1700 unsafe {
1706 *out = StbString::from_string(payload);
1707 }
1708 rc
1709}
1710
1711#[cfg(test)]
1720mod tests {
1721 use super::*;
1722 use std::sync::Mutex;
1723
1724 #[tokio::test(flavor = "current_thread")]
1725 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1726 let master = ActionBridge::new(
1727 Handle::current(),
1728 Arc::new(MockHost {
1729 prompt: String::new(),
1730 saw: Mutex::new(Vec::new()),
1731 }),
1732 );
1733 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1734 let owner = crate::HostApi::with_action_bridge(
1735 "owner",
1736 crate::ExtensionRegistry::new(),
1737 diag.clone(),
1738 master.clone(),
1739 );
1740 let other = crate::HostApi::with_action_bridge(
1741 "other",
1742 crate::ExtensionRegistry::new(),
1743 diag,
1744 master.clone(),
1745 );
1746 let vtable = owner.build_vtable();
1747 let claim = r#"{"ownsStdio":true}"#;
1748 {
1749 let _registration = crate::enter_current_api(&owner);
1750 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1751 }
1752 let other_vtable = other.build_vtable();
1753 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1754 let mut out = StbString::empty();
1755 assert_eq!(
1757 (other_vtable.runtime_action)(
1758 RuntimeActionId::StdioLifecycle as u32,
1759 finish,
1760 &mut out,
1761 other_vtable.user_data
1762 ),
1763 1
1764 );
1765 unsafe { (other_vtable.free_string)(out) };
1767 assert_eq!(
1768 (vtable.runtime_action)(
1769 RuntimeActionId::StdioLifecycle as u32,
1770 finish,
1771 &mut out,
1772 vtable.user_data
1773 ),
1774 0
1775 );
1776 unsafe { (vtable.free_string)(out) };
1778 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1779 {
1780 let _registration = crate::enter_current_api(&other);
1781 assert_ne!(
1782 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1783 0
1784 );
1785 }
1786 assert!(master
1787 .stdio_lifecycle()
1788 .validate()
1789 .unwrap_err()
1790 .contains("owner"));
1791 }
1792
1793 struct MockHost {
1798 prompt: String,
1799 saw: Mutex<Vec<RuntimeActionId>>,
1800 }
1801
1802 #[test]
1803 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1804 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1805 if std::env::var_os(CHILD).is_some() {
1806 let runtime = tokio::runtime::Builder::new_multi_thread()
1807 .worker_threads(1)
1808 .enable_all()
1809 .build()
1810 .unwrap();
1811 runtime.block_on(async {
1812 tokio::spawn(async {
1815 let host = Arc::new(MockHost {
1816 prompt: "worker callback".into(),
1817 saw: Mutex::new(Vec::new()),
1818 });
1819 let bridge = ActionBridge::new(Handle::current(), host);
1820 let mut out = StbString::empty();
1821 assert_eq!(
1822 trampoline_runtime_action(
1823 RuntimeActionId::GetSystemPrompt.into(),
1824 StbStringRef::from_str("{}"),
1825 &mut out,
1826 bridge.user_data(),
1827 ),
1828 0
1829 );
1830 let text = unsafe {
1832 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1833 out,
1834 Some(crate::host_free_string),
1835 )
1836 }
1837 .to_string_lossy();
1838 assert_eq!(
1839 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1840 "worker callback"
1841 );
1842 })
1843 .await
1844 .unwrap();
1845 });
1846 return;
1847 }
1848 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1851 .args([
1852 "--exact",
1853 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1854 "--nocapture",
1855 ])
1856 .env(CHILD, "1")
1857 .stdout(std::process::Stdio::piped())
1858 .stderr(std::process::Stdio::piped())
1859 .spawn()
1860 .unwrap();
1861 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1862 while child.try_wait().unwrap().is_none() {
1863 if std::time::Instant::now() >= deadline {
1864 let _ = child.kill();
1865 let _ = child.wait();
1866 panic!("runtime action blocked the worker needed by its own dispatch");
1867 }
1868 std::thread::sleep(std::time::Duration::from_millis(10));
1869 }
1870 let output = child.wait_with_output().unwrap();
1871 assert!(
1872 output.status.success(),
1873 "{}\n{}",
1874 String::from_utf8_lossy(&output.stdout),
1875 String::from_utf8_lossy(&output.stderr)
1876 );
1877 }
1878
1879 #[async_trait::async_trait]
1880 impl RuntimeActionHost for MockHost {
1881 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1882 unreachable!("not under test")
1883 }
1884 async fn send_user_message(
1885 &self,
1886 _: serde_json::Value,
1887 ) -> Result<serde_json::Value, String> {
1888 unreachable!("not under test")
1889 }
1890 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1891 unreachable!("not under test")
1892 }
1893 async fn set_session_name(
1894 &self,
1895 _: serde_json::Value,
1896 ) -> Result<serde_json::Value, String> {
1897 unreachable!("not under test")
1898 }
1899 async fn get_active_tools(
1900 &self,
1901 _: serde_json::Value,
1902 ) -> Result<serde_json::Value, String> {
1903 unreachable!("not under test")
1904 }
1905 async fn set_active_tools(
1906 &self,
1907 _: serde_json::Value,
1908 ) -> Result<serde_json::Value, String> {
1909 unreachable!("not under test")
1910 }
1911 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1912 unreachable!("not under test")
1913 }
1914 async fn get_thinking_level(
1915 &self,
1916 _: serde_json::Value,
1917 ) -> Result<serde_json::Value, String> {
1918 unreachable!("not under test")
1919 }
1920 async fn set_thinking_level(
1921 &self,
1922 _: serde_json::Value,
1923 ) -> Result<serde_json::Value, String> {
1924 unreachable!("not under test")
1925 }
1926 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1927 unreachable!("not under test")
1928 }
1929 async fn get_system_prompt(
1930 &self,
1931 _: serde_json::Value,
1932 ) -> Result<serde_json::Value, String> {
1933 self.saw
1934 .lock()
1935 .unwrap()
1936 .push(RuntimeActionId::GetSystemPrompt);
1937 Ok(serde_json::json!({ "prompt": self.prompt }))
1938 }
1939 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1940 unreachable!("not under test")
1941 }
1942 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1943 unreachable!("not under test")
1944 }
1945 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1946 unreachable!("not under test")
1947 }
1948 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1949 unreachable!("not under test")
1950 }
1951 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1952 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1953 Ok(serde_json::Value::Null)
1954 }
1955 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1956 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1957 Ok(serde_json::json!({ "value": true }))
1958 }
1959 }
1960
1961 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1965 async fn trampoline_round_trips_get_system_prompt() {
1966 let host = Arc::new(MockHost {
1967 prompt: "hello from host".to_string(),
1968 saw: Mutex::new(Vec::new()),
1969 });
1970 let host_for_assert = Arc::clone(&host);
1971 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1972 let runtime = tokio::runtime::Handle::current();
1973 let bridge = ActionBridge::new(runtime, host_dyn);
1974 let user_data = bridge.user_data();
1975
1976 let args_str = "{}";
1978 let args_ref = StbStringRef::from_str(args_str);
1979
1980 let mut out = StbString::empty();
1981 let rc = trampoline_runtime_action(
1982 RuntimeActionId::GetSystemPrompt.into(),
1983 args_ref,
1984 &mut out as *mut StbString,
1985 user_data,
1986 );
1987 assert_eq!(rc, 0, "success return code");
1988
1989 let json_text = unsafe {
1992 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1993 }
1994 .to_string_lossy();
1995 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1996 assert_eq!(parsed["prompt"], "hello from host");
1997
1998 let saw = host_for_assert.saw.lock().unwrap().clone();
2001 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
2002 }
2003
2004 #[tokio::test]
2010 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
2011 let host = Arc::new(MockHost {
2012 prompt: String::new(),
2013 saw: Mutex::new(Vec::new()),
2014 });
2015 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2016 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2017 let mailbox = bridge.editor_text_mailbox();
2018 let user_data = bridge.user_data();
2019
2020 let args_ref =
2021 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
2022 let mut out = StbString::empty();
2023 let rc = trampoline_runtime_action(
2024 RuntimeActionId::SetEditorText.into(),
2025 args_ref,
2026 &mut out as *mut StbString,
2027 user_data,
2028 );
2029 assert_eq!(rc, 0, "success return code");
2030
2031 let parsed: serde_json::Value = serde_json::from_str(
2033 &unsafe {
2034 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2035 }
2036 .to_string_lossy(),
2037 )
2038 .expect("valid json");
2039 assert_eq!(parsed["ok"], serde_json::json!(true));
2040 assert_eq!(parsed["chars"], serde_json::json!(10));
2041
2042 let edit = mailbox.take_pending().expect("queued edit");
2044 assert_eq!(edit.text, "from voice");
2045 assert_eq!(edit.mode, crate::EditorTextMode::Append);
2046 assert_eq!(edit.auto_send_ms, Some(2000));
2047 assert!(mailbox.take_pending().is_none());
2048 }
2049
2050 #[tokio::test]
2051 async fn trampoline_null_user_data_returns_minus_one() {
2052 let args_ref = StbStringRef::from_str("{}");
2053 let mut out = StbString::empty();
2054 let rc = trampoline_runtime_action(
2055 RuntimeActionId::GetSystemPrompt.into(),
2056 args_ref,
2057 &mut out as *mut StbString,
2058 std::ptr::null_mut(),
2059 );
2060 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
2061 }
2062
2063 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2064 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
2065 use std::sync::atomic::{AtomicUsize, Ordering};
2069 let reload_calls = Arc::new(AtomicUsize::new(0));
2070 let reload_calls_for_cb = Arc::clone(&reload_calls);
2071 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
2072 Arc::new(move || {
2073 let c = Arc::clone(&reload_calls_for_cb);
2074 Box::pin(async move {
2075 c.fetch_add(1, Ordering::SeqCst);
2076 })
2077 });
2078 let host = Arc::new(MockHost {
2079 prompt: String::new(),
2080 saw: Mutex::new(Vec::new()),
2081 });
2082 let host_for_assert = Arc::clone(&host);
2083 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2084 let runtime = tokio::runtime::Handle::current();
2085 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
2086 let user_data = bridge.user_data();
2087
2088 let args_ref = StbStringRef::from_str("{}");
2089 let mut out = StbString::empty();
2090 let rc = trampoline_runtime_action(
2091 RuntimeActionId::Reload.into(),
2092 args_ref,
2093 &mut out as *mut StbString,
2094 user_data,
2095 );
2096 assert_eq!(rc, 0);
2097 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
2099 assert!(host_for_assert.saw.lock().unwrap().is_empty());
2100 drop(unsafe {
2102 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2103 });
2104 }
2105
2106 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2111 async fn trampoline_rejects_stale_bridge_with_invalidate() {
2112 let host = Arc::new(MockHost {
2113 prompt: String::new(),
2114 saw: Mutex::new(Vec::new()),
2115 });
2116 let host_for_assert = Arc::clone(&host);
2117 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2118 let runtime = tokio::runtime::Handle::current();
2119 let bridge = ActionBridge::new(runtime, host_dyn);
2120 let user_data = bridge.user_data();
2121
2122 bridge.invalidate();
2124 assert!(!bridge.is_active());
2125
2126 let args_ref = StbStringRef::from_str("{}");
2127 let mut out = StbString::empty();
2128 let rc = trampoline_runtime_action(
2129 RuntimeActionId::GetSystemPrompt.into(),
2130 args_ref,
2131 &mut out as *mut StbString,
2132 user_data,
2133 );
2134 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
2136 let json_text = unsafe {
2138 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2139 }
2140 .to_string_lossy();
2141 assert!(
2142 json_text.contains("stale"),
2143 "stale-bridge error payload: {json_text}"
2144 );
2145
2146 assert!(
2147 host_for_assert.saw.lock().unwrap().is_empty(),
2148 "host dispatch must NOT run on a stale bridge"
2149 );
2150 }
2151
2152 #[tokio::test]
2153 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
2154 let host = Arc::new(MockHost {
2155 prompt: String::new(),
2156 saw: Mutex::new(Vec::new()),
2157 });
2158 let host_for_assert = Arc::clone(&host);
2159 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2160 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2161 let user_data = bridge.user_data();
2162
2163 let mut out = StbString::empty();
2164 let rc = trampoline_runtime_action(
2165 0xFFFF_FFFE,
2166 StbStringRef::from_str("{}"),
2167 &mut out,
2168 user_data,
2169 );
2170
2171 assert_eq!(rc, 2, "unknown action id must be a protocol error");
2172 let payload: serde_json::Value = serde_json::from_str(
2173 &unsafe {
2174 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2175 }
2176 .to_string_lossy(),
2177 )
2178 .expect("structured error JSON");
2179 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
2180
2181 assert!(
2182 host_for_assert.saw.lock().unwrap().is_empty(),
2183 "unknown action id must not reach host dispatch"
2184 );
2185 }
2186
2187 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2188 async fn v2_trampoline_accepts_new_action() {
2189 let host = Arc::new(MockHost {
2190 prompt: "v2".to_string(),
2191 saw: Mutex::new(Vec::new()),
2192 });
2193 let host_for_assert = Arc::clone(&host);
2194 let host_dyn: Arc<dyn RuntimeActionHost> = host;
2195 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
2196 let user_data = bridge.user_data();
2197
2198 let mut v2_out = StbString::empty();
2199 assert_eq!(
2200 trampoline_runtime_action(
2201 RuntimeActionId::GetCliFlag.into(),
2202 StbStringRef::from_str(r#"{"name":"flag"}"#),
2203 &mut v2_out,
2204 user_data,
2205 ),
2206 0
2207 );
2208 drop(unsafe {
2210 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
2211 });
2212
2213 assert_eq!(
2214 *host_for_assert.saw.lock().unwrap(),
2215 vec![RuntimeActionId::GetCliFlag]
2216 );
2217 }
2218
2219 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2224 async fn reload_mailbox_signals_installed_receiver() {
2225 let mailbox = ReloadMailbox::new();
2226 assert!(matches!(mailbox.signal(), Err(())));
2228
2229 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2230 mailbox.install(tx);
2231 let cb = reload_callback_from_mailbox(mailbox.clone());
2232 cb().await;
2233 assert_eq!(
2234 rx.recv().await,
2235 Some(()),
2236 "installed receiver saw the signal"
2237 );
2238 mailbox.clear();
2239 }
2240
2241 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
2244 serde_json::json!({
2245 "op": "open",
2246 "requestId": id,
2247 "toolCallId": tool_call,
2248 "ui": {"kind": "selector", "questions": [
2249 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
2250 ]},
2251 })
2252 }
2253
2254 #[test]
2257 fn ui_dialog_open_requires_an_attached_ui() {
2258 let mailbox = UiDialogMailbox::new();
2259 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
2260 assert!(error.contains("interactive UI"), "got: {error}");
2261 }
2262
2263 #[test]
2264 fn ui_dialog_open_poll_answer_round_trip() {
2265 let mailbox = UiDialogMailbox::new();
2266 mailbox.attach();
2267
2268 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
2269 assert_eq!(opened["status"], "pending");
2270 assert_eq!(opened["toolCallId"], "c1");
2271
2272 let request = mailbox.take_pending().expect("request visible to TUI");
2273 assert_eq!(request.request_id, "r1");
2274 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
2275 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2277 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
2278
2279 mailbox
2280 .respond("r1", serde_json::json!("7890"))
2281 .expect("respond");
2282 let answered = mailbox.poll("r1").unwrap();
2283 assert_eq!(answered["status"], "answered");
2284 assert_eq!(answered["answer"], "7890");
2285 assert!(mailbox.poll("r1").is_err());
2287 }
2288
2289 #[test]
2290 fn ui_dialog_cancel_is_reported_once() {
2291 let mailbox = UiDialogMailbox::new();
2292 mailbox.attach();
2293 mailbox.handle(open_args("r1", "c1")).unwrap();
2294 mailbox.cancel("r1").unwrap();
2295 let cancelled = mailbox.poll("r1").unwrap();
2296 assert_eq!(cancelled["status"], "cancelled");
2297 assert!(mailbox.poll("r1").is_err());
2298 }
2299
2300 #[test]
2301 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
2302 let mailbox = UiDialogMailbox::new();
2303 mailbox.attach();
2304 mailbox.handle(open_args("r1", "c1")).unwrap();
2305 mailbox.handle(open_args("r2", "c2")).unwrap();
2306 mailbox.cancel_tool_call("c1");
2307 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2308 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
2309 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
2310 assert_eq!(
2311 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
2312 "cancelled"
2313 );
2314 assert!(mailbox.take_pending().is_none());
2315 }
2316
2317 #[test]
2318 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
2319 let mailbox = UiDialogMailbox::new();
2320 mailbox.attach();
2321 mailbox.handle(open_args("r1", "c1")).unwrap();
2322 mailbox.handle(open_args("r2", "c2")).unwrap();
2323
2324 let first = mailbox.take_pending().unwrap().request_id;
2325 let second = mailbox.take_pending().unwrap().request_id;
2326 assert_ne!(first, second);
2327
2328 mailbox
2330 .respond(&second, serde_json::json!("second"))
2331 .unwrap();
2332 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
2333 let second_answer = mailbox.poll(&second).unwrap();
2334 assert_eq!(second_answer["answer"], "second");
2335
2336 mailbox.respond(&first, serde_json::json!("first")).unwrap();
2337 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
2338 }
2339
2340 #[test]
2341 fn ui_dialog_detach_cancels_outstanding() {
2342 let mailbox = UiDialogMailbox::new();
2343 mailbox.attach();
2344 mailbox.handle(open_args("r1", "c1")).unwrap();
2345 mailbox.detach();
2346 assert!(!mailbox.is_attached());
2347 assert_eq!(mailbox.pending_len(), 0);
2348 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
2350 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
2351 }
2352
2353 #[test]
2354 fn ui_dialog_duplicate_open_does_not_reenqueue() {
2355 let mailbox = UiDialogMailbox::new();
2356 mailbox.attach();
2357 mailbox.handle(open_args("r1", "c1")).unwrap();
2358 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
2359 assert_eq!(again["status"], "pending");
2360 assert_eq!(mailbox.pending_len(), 1);
2361 mailbox.take_pending().unwrap();
2362 assert!(mailbox.take_pending().is_none());
2363 }
2364
2365 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2370 async fn list_commands_routes_to_the_host_and_defaults_to_empty() {
2371 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2372 prompt: String::new(),
2373 saw: Mutex::new(Vec::new()),
2374 });
2375 let bridge = ActionBridge::with_ui_dialog(
2376 tokio::runtime::Handle::current(),
2377 host,
2378 UiDialogMailbox::new(),
2379 );
2380 let user_data = bridge.user_data();
2381 let mut out = StbString::empty();
2382 let rc = trampoline_runtime_action(
2383 u32::from(RuntimeActionId::ListCommands),
2384 StbStringRef::from_str("{}"),
2385 &mut out,
2386 user_data,
2387 );
2388 assert_eq!(
2389 rc, 0,
2390 "ListCommands must not fail on a host with no registry"
2391 );
2392 let payload: serde_json::Value = serde_json::from_str(
2393 &unsafe {
2394 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2395 }
2396 .to_string_lossy(),
2397 )
2398 .expect("JSON");
2399 assert_eq!(
2400 payload
2401 .get("commands")
2402 .and_then(|c| c.as_array())
2403 .map(Vec::len),
2404 Some(0),
2405 "an empty registry is an empty list, not an error: {payload}"
2406 );
2407 }
2408
2409 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2413 async fn v2_dispatch_routes_ui_dialog_action_17() {
2414 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2415 prompt: String::new(),
2416 saw: Mutex::new(Vec::new()),
2417 });
2418 let bridge = ActionBridge::with_ui_dialog(
2419 tokio::runtime::Handle::current(),
2420 host,
2421 UiDialogMailbox::new(),
2422 );
2423 let user_data = bridge.user_data();
2424
2425 let mut out = StbString::empty();
2426 let rc = trampoline_runtime_action(
2427 17,
2428 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
2429 &mut out,
2430 user_data,
2431 );
2432 assert_eq!(rc, 1);
2433 let payload: serde_json::Value = serde_json::from_str(
2434 &unsafe {
2435 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2436 }
2437 .to_string_lossy(),
2438 )
2439 .expect("structured error JSON");
2440 assert!(
2441 payload["error"]
2442 .as_str()
2443 .is_some_and(|error| error.contains("interactive UI")),
2444 "unexpected payload: {payload}"
2445 );
2446
2447 let one_past_last = rpi_plugin_sdk::RuntimeActionId::MAX + 1;
2451 let mut out = StbString::empty();
2452 assert_eq!(
2453 trampoline_runtime_action(
2454 one_past_last,
2455 StbStringRef::from_str("{}"),
2456 &mut out,
2457 user_data
2458 ),
2459 2
2460 );
2461 drop(unsafe {
2463 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2464 });
2465 }
2466
2467 #[tokio::test(flavor = "current_thread")]
2468 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
2469 let host = Arc::new(MockHost {
2470 prompt: String::new(),
2471 saw: Mutex::new(Vec::new()),
2472 });
2473 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
2474 let a = root.for_plugin("a");
2475 let b = root.for_plugin("b");
2476 let mailbox = root.extension_status_mailbox();
2477 for plugin in [&a, &b] {
2478 let mut out = StbString::empty();
2479 assert_eq!(
2480 trampoline_runtime_action(
2481 22,
2482 StbStringRef::from_str(
2483 r#"{"op":"register","id":"same","slot":"footer.after"}"#
2484 ),
2485 &mut out,
2486 plugin.user_data()
2487 ),
2488 0
2489 );
2490 drop(unsafe {
2492 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2493 });
2494 }
2495 assert_eq!(mailbox.panels().len(), 2);
2496 drop(a);
2497 assert_eq!(mailbox.panels().len(), 1);
2498 root.invalidate();
2499 assert!(mailbox.panels().is_empty());
2500 }
2501
2502 #[test]
2508 fn relay_ui_dialog_runs_off_the_executor() {
2509 let mailbox = UiDialogMailbox::new();
2510 mailbox.attach();
2511 let out = dispatch_relay_ui_dialog(&mailbox, serde_json::json!({"op":"release"}))
2512 .expect("release is a supported op");
2513 assert_eq!(out["requeued"], serde_json::json!(0));
2514 }
2515
2516 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2519 async fn v2_dispatch_routes_set_status_action_18() {
2520 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2521 prompt: String::new(),
2522 saw: Mutex::new(Vec::new()),
2523 });
2524 let status = crate::status::ExtensionStatusMailbox::new();
2525 let bridge = ActionBridge::with_reload_and_ui(
2526 tokio::runtime::Handle::current(),
2527 host,
2528 Arc::new(|| Box::pin(async {})),
2529 UiDialogMailbox::new(),
2530 status.clone(),
2531 crate::editor_text::EditorTextMailbox::new(),
2532 );
2533 let user_data = bridge.user_data();
2534
2535 let mut out = StbString::empty();
2536 let rc = trampoline_runtime_action(
2537 18,
2538 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2539 &mut out,
2540 user_data,
2541 );
2542 assert_eq!(rc, 0);
2543 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2544 drop(unsafe {
2546 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2547 });
2548
2549 let mut out = StbString::empty();
2551 assert_eq!(
2552 trampoline_runtime_action(
2553 18,
2554 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2555 &mut out,
2556 user_data
2557 ),
2558 0
2559 );
2560 assert_eq!(status.text(), "");
2561 drop(unsafe {
2563 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2564 });
2565
2566 let mut out = StbString::empty();
2568 assert_eq!(
2569 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2570 1
2571 );
2572 drop(unsafe {
2574 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2575 });
2576 }
2577 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2584 async fn trampoline_ceiling_matches_the_highest_defined_action() {
2585 let highest = RuntimeActionId::MAX;
2586 assert_eq!(
2587 highest,
2588 RuntimeActionId::ALL
2589 .iter()
2590 .copied()
2591 .map(u32::from)
2592 .max()
2593 .unwrap(),
2594 "RuntimeActionId::MAX must be the maximum of RuntimeActionId::ALL"
2595 );
2596 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2597 prompt: String::new(),
2598 saw: Mutex::new(Vec::new()),
2599 });
2600 let bridge = ActionBridge::with_ui_dialog(
2601 tokio::runtime::Handle::current(),
2602 host,
2603 UiDialogMailbox::new(),
2604 );
2605 let mut out = StbString::empty();
2606 let rc = trampoline_runtime_action(
2607 highest,
2608 StbStringRef::from_str("{}"),
2609 &mut out,
2610 bridge.user_data(),
2611 );
2612 assert_ne!(
2613 rc, 2,
2614 "the highest defined action id ({highest}) must not be rejected as unknown — \
2615 the trampoline ceiling is stale"
2616 );
2617 if rc == 0 {
2618 drop(unsafe {
2620 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2621 });
2622 }
2623 }
2624}
2625
2626impl Drop for ActionBridge {
2627 fn drop(&mut self) {
2628 bridges()
2629 .lock()
2630 .unwrap_or_else(|e| e.into_inner())
2631 .retain(|_, entry| !std::ptr::eq(entry.bridge.as_ptr(), self));
2632 if self.invocation.is_some() {
2633 return;
2634 }
2635 if let Some((owner, _)) = &self.plugin_identity {
2636 self.ext_status.remove_owner(*owner);
2637 self.host.cancel_child_agents(*owner);
2638 self.host.cancel_side_questions(*owner);
2639 }
2640 }
2641}
2642
2643#[cfg(test)]
2644mod runtime_tests;
2645
2646#[cfg(test)]
2647mod relay_tests {
2648 use super::*;
2649
2650 fn request(id: &str) -> UiDialogRequest {
2651 UiDialogRequest {
2652 request_id: id.to_string(),
2653 tool_call_id: Some(format!("tool-{id}")),
2654 ui: serde_json::json!({"question": "ok?"}),
2655 raw: serde_json::json!({}),
2656 }
2657 }
2658
2659 #[test]
2663 fn reserved_requests_are_invisible_to_the_tui() {
2664 let mailbox = UiDialogMailbox::new();
2665 mailbox.attach();
2666 let reserved = format!("{PLUGIN_RESERVED_PREFIX}gate-1");
2667 mailbox
2668 .handle(serde_json::json!({
2669 "op": "open",
2670 "requestId": reserved,
2671 "toolCallId": "t1",
2672 "ui": {}
2673 }))
2674 .unwrap();
2675 mailbox
2676 .handle(serde_json::json!({
2677 "op": "open",
2678 "requestId": "plain-1",
2679 "toolCallId": "t2",
2680 "ui": {}
2681 }))
2682 .unwrap();
2683
2684 let taken = mailbox
2686 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2687 .expect("the unreserved request must reach the TUI");
2688 assert_eq!(taken.request_id, "plain-1");
2689
2690 let own = mailbox
2692 .take_pending_filtered(|r| mailbox.is_reserved(&r.request_id))
2693 .expect("the plugin must still see its reserved request");
2694 assert_eq!(own.request_id, reserved);
2695 }
2696
2697 #[test]
2699 fn release_or_detach_clears_takeover_and_requeues_unanswered() {
2700 let mailbox = UiDialogMailbox::new();
2701 mailbox.attach();
2702 mailbox
2703 .handle(serde_json::json!({
2704 "op": "open", "requestId": "plain-r", "toolCallId": "t", "ui": {}
2705 }))
2706 .unwrap();
2707
2708 mailbox.set_external_takeover(true);
2710 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2711 assert_eq!(mailbox.release_takeover(), 1);
2712 assert!(
2713 !mailbox.external_takeover(),
2714 "release must hand delivery back to the TUI"
2715 );
2716 assert_eq!(
2718 mailbox
2719 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2720 .map(|r| r.request_id),
2721 Some("plain-r".to_string())
2722 );
2723
2724 mailbox.set_external_takeover(true);
2726 mailbox.detach();
2727 assert!(!mailbox.external_takeover(), "detach must clear takeover");
2728 }
2729
2730 #[test]
2733 fn tui_take_is_atomic_against_takeover() {
2734 let mailbox = UiDialogMailbox::new();
2735 mailbox.attach();
2736 mailbox.set_external_takeover(true);
2737 mailbox
2738 .handle(serde_json::json!({
2739 "op": "open", "requestId": "plain-a", "toolCallId": "t", "ui": {}
2740 }))
2741 .unwrap();
2742 assert!(
2743 mailbox.take_pending_unless_external(|_| true).is_none(),
2744 "the TUI must stand down while a relay owns delivery"
2745 );
2746 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2748 }
2749
2750 #[test]
2753 fn relay_take_returns_unreserved_and_keeps_reserved() {
2754 let mailbox = UiDialogMailbox::new();
2755 mailbox.attach();
2756 mailbox
2757 .handle(serde_json::json!({
2758 "op": "open",
2759 "requestId": format!("{PLUGIN_RESERVED_PREFIX}keep"),
2760 "toolCallId": "t1",
2761 "ui": {}
2762 }))
2763 .unwrap();
2764 mailbox
2765 .handle(serde_json::json!({
2766 "op": "open",
2767 "requestId": "relay-1",
2768 "toolCallId": "t2",
2769 "ui": {}
2770 }))
2771 .unwrap();
2772
2773 let taken = mailbox.take_all_unreserved();
2774 assert_eq!(taken.len(), 1);
2775 assert_eq!(taken[0].request_id, "relay-1");
2776 assert_eq!(mailbox.pending_len(), 1);
2778 }
2779
2780 #[test]
2783 fn a_relayed_request_can_still_be_answered() {
2784 let mailbox = UiDialogMailbox::new();
2785 mailbox.attach();
2786 mailbox
2787 .handle(serde_json::json!({
2788 "op": "open",
2789 "requestId": "relay-1",
2790 "toolCallId": "t1",
2791 "ui": {}
2792 }))
2793 .unwrap();
2794 let taken = mailbox.take_all_unreserved();
2795 assert_eq!(taken.len(), 1);
2796
2797 mailbox
2798 .handle(serde_json::json!({
2799 "op": "respond",
2800 "requestId": "relay-1",
2801 "answer": {"ok": true}
2802 }))
2803 .unwrap();
2804 assert_eq!(
2806 mailbox
2807 .take_pending_filtered(|_| true)
2808 .map(|r| r.request_id),
2809 None,
2810 "an answered request must not resurface"
2811 );
2812 }
2813
2814 #[test]
2817 fn take_pending_still_takes_reserved_entries() {
2818 let mailbox = UiDialogMailbox::new();
2819 mailbox.attach();
2820 let reserved = format!("{PLUGIN_RESERVED_PREFIX}x");
2821 mailbox
2822 .handle(serde_json::json!({
2823 "op": "open",
2824 "requestId": reserved,
2825 "toolCallId": "t1",
2826 "ui": {}
2827 }))
2828 .unwrap();
2829 assert_eq!(
2830 mailbox.take_pending().map(|r| r.request_id),
2831 Some(format!("{PLUGIN_RESERVED_PREFIX}x"))
2832 );
2833 }
2834
2835 #[test]
2841 fn external_takeover_makes_the_tui_stand_down() {
2842 let mailbox = UiDialogMailbox::new();
2843 mailbox.attach();
2844 mailbox
2845 .handle(serde_json::json!({
2846 "op": "open",
2847 "requestId": "plain-1",
2848 "toolCallId": "t1",
2849 "ui": {}
2850 }))
2851 .unwrap();
2852
2853 assert!(!mailbox.external_takeover());
2854 mailbox.set_external_takeover(true);
2855 assert!(mailbox.external_takeover());
2856
2857 let tui_take = |m: &UiDialogMailbox| {
2859 if m.external_takeover() {
2860 return None;
2861 }
2862 m.take_pending_filtered(|r| !m.is_reserved(&r.request_id))
2863 };
2864 assert!(
2865 tui_take(&mailbox).is_none(),
2866 "the TUI must not steal a request an external UI owns"
2867 );
2868 assert_eq!(mailbox.take_all_unreserved().len(), 1);
2870 }
2871
2872 #[test]
2874 fn clearing_takeover_returns_dialogs_to_the_tui() {
2875 let mailbox = UiDialogMailbox::new();
2876 mailbox.attach();
2877 mailbox.set_external_takeover(true);
2878 mailbox.set_external_takeover(false);
2879 mailbox
2880 .handle(serde_json::json!({
2881 "op": "open",
2882 "requestId": "plain-2",
2883 "toolCallId": "t2",
2884 "ui": {}
2885 }))
2886 .unwrap();
2887 assert_eq!(
2888 mailbox
2889 .take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
2890 .map(|r| r.request_id),
2891 Some("plain-2".to_string())
2892 );
2893 }
2894
2895 #[test]
2896 fn request_helper_matches_the_prefix_rule() {
2897 let mailbox = UiDialogMailbox::new();
2898 mailbox.attach();
2899 assert!(mailbox.is_reserved(&request(PLUGIN_RESERVED_PREFIX).request_id));
2900 assert!(!mailbox.is_reserved(&request("plain").request_id));
2901 }
2902}