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
58#[derive(Clone, Debug, PartialEq)]
65pub struct UiDialogRequest {
66 pub request_id: String,
67 pub tool_call_id: Option<String>,
68 pub ui: serde_json::Value,
69 pub raw: serde_json::Value,
70}
71
72struct UiDialogEntry {
73 request: UiDialogRequest,
74 answer: Option<serde_json::Value>,
75 cancelled: bool,
76}
77
78struct UiDialogState {
79 attached: bool,
80 pending: VecDeque<String>,
81 active: HashMap<String, UiDialogEntry>,
82 cancelled_tool_calls: std::collections::HashSet<String>,
83}
84
85#[derive(Clone)]
89pub struct UiDialogMailbox {
90 state: Arc<Mutex<UiDialogState>>,
91}
92
93impl Default for UiDialogMailbox {
94 fn default() -> Self {
95 Self::new()
96 }
97}
98
99impl UiDialogMailbox {
100 pub fn new() -> Self {
101 Self {
102 state: Arc::new(Mutex::new(UiDialogState {
103 attached: false,
104 pending: VecDeque::new(),
105 active: HashMap::new(),
106 cancelled_tool_calls: std::collections::HashSet::new(),
107 })),
108 }
109 }
110
111 pub fn attach(&self) {
113 self.state
114 .lock()
115 .expect("ui dialog mailbox poisoned")
116 .attached = true;
117 }
118
119 pub fn detach(&self) {
122 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
123 state.attached = false;
124 state.pending.clear();
125 for entry in state.active.values_mut() {
126 entry.cancelled = true;
127 entry.answer = None;
128 }
129 }
130
131 pub fn is_attached(&self) -> bool {
132 self.state
133 .lock()
134 .expect("ui dialog mailbox poisoned")
135 .attached
136 }
137
138 pub fn take_pending(&self) -> Option<UiDialogRequest> {
142 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
143 while let Some(request_id) = state.pending.pop_front() {
144 if let Some(entry) = state.active.get(&request_id) {
145 if !entry.cancelled && entry.answer.is_none() {
146 return Some(entry.request.clone());
147 }
148 }
149 }
150 None
151 }
152
153 pub fn pending_len(&self) -> usize {
154 self.state
155 .lock()
156 .expect("ui dialog mailbox poisoned")
157 .pending
158 .len()
159 }
160
161 pub fn is_pending(&self, request_id: &str) -> bool {
163 self.state
164 .lock()
165 .expect("ui dialog mailbox poisoned")
166 .active
167 .get(request_id)
168 .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
169 }
170
171 pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
174 let mut state = self
175 .state
176 .lock()
177 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
178 let entry = state
179 .active
180 .get_mut(request_id)
181 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
182 if entry.cancelled {
183 return Err(format!("UI request `{request_id}` was cancelled"));
184 }
185 if entry.answer.is_none() {
186 entry.answer = Some(answer);
187 }
188 Ok(())
189 }
190
191 pub fn cancel(&self, request_id: &str) -> Result<(), String> {
194 let mut state = self
195 .state
196 .lock()
197 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
198 let entry = state
199 .active
200 .get_mut(request_id)
201 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
202 entry.cancelled = true;
203 entry.answer = None;
204 state.pending.retain(|id| id != request_id);
205 Ok(())
206 }
207
208 pub fn cancel_all(&self) {
210 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
211 state.pending.clear();
212 for entry in state.active.values_mut() {
213 entry.cancelled = true;
214 entry.answer = None;
215 }
216 }
217
218 pub fn cancel_tool_call(&self, tool_call_id: &str) {
221 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
222 state.cancelled_tool_calls.insert(tool_call_id.to_string());
225 for entry in state.active.values_mut() {
226 if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
227 entry.cancelled = true;
228 entry.answer = None;
229 }
230 }
231 let cancelled: std::collections::HashSet<String> = state
232 .active
233 .iter()
234 .filter(|(_, entry)| entry.cancelled)
235 .map(|(id, _)| id.clone())
236 .collect();
237 state.pending.retain(|id| !cancelled.contains(id));
238 }
239
240 pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
243 let mut state = self
244 .state
245 .lock()
246 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
247 let terminal = match state.active.get(request_id) {
248 Some(entry) if entry.cancelled => Some(serde_json::json!({
249 "status": "cancelled",
250 "requestId": request_id,
251 "toolCallId": entry.request.tool_call_id,
252 })),
253 Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
254 "status": "answered",
255 "requestId": request_id,
256 "toolCallId": entry.request.tool_call_id,
257 "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
258 })),
259 Some(entry) => Some(serde_json::json!({
260 "status": "pending",
261 "requestId": request_id,
262 "toolCallId": entry.request.tool_call_id,
263 })),
264 None => None,
265 };
266 let Some(result) = terminal else {
267 return Err(format!("unknown UI request `{request_id}`"));
268 };
269 if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
270 state.active.remove(request_id);
271 }
272 Ok(result)
273 }
274
275 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
279 let op = args
280 .get("op")
281 .and_then(serde_json::Value::as_str)
282 .unwrap_or("open");
283 let request_id = || {
284 args.get("requestId")
285 .or_else(|| args.get("request_id"))
286 .and_then(serde_json::Value::as_str)
287 .map(str::to_owned)
288 .filter(|id| !id.trim().is_empty())
289 .ok_or_else(|| "missing non-empty `requestId`".to_string())
290 };
291 match op {
292 "open" => {
293 let id = request_id()?;
294 let mut state = self
295 .state
296 .lock()
297 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
298 if !state.attached {
299 return Err("ask_user requires an interactive UI".to_string());
300 }
301 if let Some(entry) = state.active.get(&id) {
302 return Ok(if entry.cancelled {
303 serde_json::json!({"status":"cancelled","requestId":id})
304 } else if let Some(answer) = &entry.answer {
305 serde_json::json!({"status":"answered","requestId":id,"answer":answer})
306 } else {
307 serde_json::json!({"status":"pending","requestId":id})
308 });
309 }
310 let tool_call_id = args
311 .get("toolCallId")
312 .or_else(|| args.get("tool_call_id"))
313 .and_then(serde_json::Value::as_str)
314 .map(str::to_owned);
315 if tool_call_id
316 .as_ref()
317 .is_some_and(|id| state.cancelled_tool_calls.contains(id))
318 {
319 return Ok(serde_json::json!({
320 "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
321 }));
322 }
323 let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
324 rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
325 state.active.insert(
326 id.clone(),
327 UiDialogEntry {
328 request: UiDialogRequest {
329 request_id: id.clone(),
330 tool_call_id: tool_call_id.clone(),
331 ui,
332 raw: args,
333 },
334 answer: None,
335 cancelled: false,
336 },
337 );
338 state.pending.push_back(id.clone());
339 Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
340 }
341 "poll" => self.poll(&request_id()?),
342 "cancel" => {
343 let id = request_id()?;
344 self.cancel(&id)?;
345 Ok(serde_json::json!({"status":"cancelled","requestId":id}))
346 }
347 "response" | "respond" => {
348 let id = request_id()?;
349 let answer = args
350 .get("answer")
351 .or_else(|| args.get("value"))
352 .cloned()
353 .unwrap_or(serde_json::Value::Null);
354 self.respond(&id, answer)?;
355 Ok(serde_json::json!({"status":"answered","requestId":id}))
356 }
357 other => Err(format!("unknown UI dialog operation `{other}`")),
358 }
359 }
360}
361
362#[async_trait::async_trait]
377pub trait RuntimeActionHost: Send + Sync {
378 fn replace_model_catalog(&self, _models: Vec<rpi_ai::Model>) {}
380 fn cancel_child_agents(&self, _owner: u64) {}
382 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
387 async fn send_user_message(&self, args: serde_json::Value)
389 -> Result<serde_json::Value, String>;
390 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
395 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
397 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
399 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
401 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
403 async fn get_thinking_level(
405 &self,
406 args: serde_json::Value,
407 ) -> Result<serde_json::Value, String>;
408 async fn set_thinking_level(
410 &self,
411 args: serde_json::Value,
412 ) -> Result<serde_json::Value, String>;
413 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
415 async fn get_system_prompt(&self, args: serde_json::Value)
417 -> Result<serde_json::Value, String>;
418 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
420 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
422 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
424 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
426 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
430
431 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
434 Err("CLI flag lookup is not configured".to_string())
435 }
436
437 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
441 Err("ask_user requires an interactive UI".to_string())
442 }
443
444 async fn get_execution_context(
446 &self,
447 _args: serde_json::Value,
448 ) -> Result<serde_json::Value, String> {
449 Err("execution context is not supported by this host".into())
450 }
451
452 async fn child_agent(
454 &self,
455 _owner: u64,
456 _args: serde_json::Value,
457 ) -> Result<serde_json::Value, String> {
458 Err("ChildAgent is not supported by this host".into())
459 }
460
461 async fn get_session_branch(
463 &self,
464 _args: serde_json::Value,
465 ) -> Result<serde_json::Value, String> {
466 Err("session branch lookup is not configured".to_string())
467 }
468}
469
470pub struct ActionBridge {
497 invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
499 execution_context: Option<serde_json::Value>,
500 stdio: crate::StdioLifecycle,
501 plugin_identity: Option<(u64, String)>,
502 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
503 pub(crate) runtime: Handle,
505 pub(crate) host: Arc<dyn RuntimeActionHost>,
507 pub(crate) reload:
509 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
510 ui_dialog: UiDialogMailbox,
513 ext_status: crate::status::ExtensionStatusMailbox,
517 editor_text: crate::editor_text::EditorTextMailbox,
521 active: Arc<AtomicBool>,
524}
525
526impl ActionBridge {
527 pub fn user_data(self: &Arc<Self>) -> *mut c_void {
529 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
530 let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
531 if let Some((&id, _)) = registry
532 .iter()
533 .find(|(_, weak)| weak.as_ptr() == Arc::as_ptr(self))
534 {
535 return id as *mut c_void;
536 }
537 let id = NEXT
538 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_add(1))
539 .expect("runtime handle space exhausted");
540 registry.insert(id, Arc::downgrade(self));
541 id as *mut c_void
542 }
543 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
544 bridges()
545 .lock()
546 .unwrap_or_else(|e| e.into_inner())
547 .get(&id)
548 .and_then(Weak::upgrade)
549 }
550 pub(crate) fn for_invocation(
551 &self,
552 child: bool,
553 signal: tokio_util::sync::CancellationToken,
554 ) -> Arc<Self> {
555 Arc::new(Self {
556 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
557 execution_context: rpi_agent::tool_execution_scope::execution_context(),
558 stdio: self.stdio.clone(),
559 plugin_identity: self.plugin_identity.clone(),
560 component_owners: self.component_owners.clone(),
561 runtime: self.runtime.clone(),
562 host: self.host.clone(),
563 reload: self.reload.clone(),
564 ui_dialog: self.ui_dialog.clone(),
565 ext_status: self.ext_status.clone(),
566 editor_text: self.editor_text.clone(),
567 active: self.active.clone(),
568 })
569 }
570 pub(crate) fn invocation_key(&self) -> Option<&str> {
571 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
572 }
573 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
574 InvocationGuard {
575 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
576 _thread_bound: std::marker::PhantomData,
577 }
578 }
579 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
582 Arc::new(Self {
583 invocation: None,
584 execution_context: None,
585 stdio: crate::StdioLifecycle::default(),
586 plugin_identity: None,
587 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
588 runtime,
589 host,
590 reload: None,
591 ui_dialog: UiDialogMailbox::new(),
592 ext_status: crate::status::ExtensionStatusMailbox::new(),
593 editor_text: crate::editor_text::EditorTextMailbox::new(),
594 active: Arc::new(AtomicBool::new(true)),
595 })
596 }
597
598 pub fn with_reload(
601 runtime: Handle,
602 host: Arc<dyn RuntimeActionHost>,
603 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
604 ) -> Arc<Self> {
605 Self::with_reload_and_ui(
606 runtime,
607 host,
608 reload,
609 UiDialogMailbox::new(),
610 crate::status::ExtensionStatusMailbox::new(),
611 crate::editor_text::EditorTextMailbox::new(),
612 )
613 }
614
615 pub fn with_ui_dialog(
617 runtime: Handle,
618 host: Arc<dyn RuntimeActionHost>,
619 ui_dialog: UiDialogMailbox,
620 ) -> Arc<Self> {
621 Arc::new(Self {
622 invocation: None,
623 execution_context: None,
624 stdio: crate::StdioLifecycle::default(),
625 plugin_identity: None,
626 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
627 runtime,
628 host,
629 reload: None,
630 ui_dialog,
631 ext_status: crate::status::ExtensionStatusMailbox::new(),
632 editor_text: crate::editor_text::EditorTextMailbox::new(),
633 active: Arc::new(AtomicBool::new(true)),
634 })
635 }
636
637 pub fn with_reload_and_ui(
639 runtime: Handle,
640 host: Arc<dyn RuntimeActionHost>,
641 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
642 ui_dialog: UiDialogMailbox,
643 ext_status: crate::status::ExtensionStatusMailbox,
644 editor_text: crate::editor_text::EditorTextMailbox,
645 ) -> Arc<Self> {
646 Arc::new(Self {
647 invocation: None,
648 execution_context: None,
649 stdio: crate::StdioLifecycle::default(),
650 plugin_identity: None,
651 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
652 runtime,
653 host,
654 reload: Some(reload),
655 ui_dialog,
656 ext_status,
657 editor_text,
658 active: Arc::new(AtomicBool::new(true)),
659 })
660 }
661
662 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
663 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
664 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
665 self.component_owners
666 .lock()
667 .unwrap_or_else(|e| e.into_inner())
668 .push(owner);
669 Arc::new(Self {
670 invocation: None,
671 execution_context: None,
672 component_owners: self.component_owners.clone(),
673 stdio: self.stdio.clone(),
674 plugin_identity: Some((owner, name.to_owned())),
675 runtime: self.runtime.clone(),
676 host: self.host.clone(),
677 reload: self.reload.clone(),
678 ui_dialog: self.ui_dialog.clone(),
679 ext_status: self.ext_status.clone(),
680 editor_text: self.editor_text.clone(),
681 active: self.active.clone(),
682 })
683 }
684
685 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
686 self.stdio.clone()
687 }
688
689 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
690 let (id, name) = self
691 .plugin_identity
692 .as_ref()
693 .ok_or("missing plugin identity")?;
694 self.stdio.claim(*id, name)
695 }
696
697 pub(crate) fn release_stdio_claim(&self) {
698 if let Some((id, _)) = &self.plugin_identity {
699 self.stdio.release_failed_registration(*id);
700 self.ext_status.remove_owner(*id);
701 }
702 }
703
704 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
706 self.ui_dialog.clone()
707 }
708
709 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
711 self.ext_status.clone()
712 }
713
714 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
716 self.editor_text.clone()
717 }
718
719 pub fn invalidate(&self) {
724 let owners = self
725 .component_owners
726 .lock()
727 .unwrap_or_else(|e| e.into_inner());
728 self.active.store(false, Ordering::SeqCst);
729 for owner in owners.iter() {
730 self.ext_status.remove_owner(*owner);
731 self.host.cancel_child_agents(*owner);
732 }
733 }
734
735 pub fn is_active(&self) -> bool {
737 self.active.load(Ordering::SeqCst)
738 }
739
740 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
747 Arc::clone(&self.host)
748 }
749}
750
751#[derive(Clone)]
764pub struct ReloadMailbox {
765 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
766}
767
768impl Default for ReloadMailbox {
769 fn default() -> Self {
770 Self {
771 tx: Arc::new(std::sync::Mutex::new(None)),
772 }
773 }
774}
775
776impl ReloadMailbox {
777 pub fn new() -> Self {
779 Self::default()
780 }
781
782 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
786 *self.tx.lock().unwrap() = Some(tx);
787 }
788
789 pub fn signal(&self) -> Result<(), ()> {
794 let g = self.tx.lock().unwrap();
795 match &*g {
796 Some(tx) => {
797 let _ = tx.send(());
798 Ok(())
799 }
800 None => Err(()),
801 }
802 }
803
804 pub fn clear(&self) {
806 *self.tx.lock().unwrap() = None;
807 }
808}
809
810pub fn reload_callback_from_mailbox(
817 mailbox: ReloadMailbox,
818) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
819 Arc::new(move || {
820 let m = mailbox.clone();
821 Box::pin(async move {
822 let _ = m.signal();
823 })
824 })
825}
826
827async fn dispatch(
838 host: &Arc<dyn RuntimeActionHost>,
839 ui_dialog: &UiDialogMailbox,
840 ext_status: &crate::status::ExtensionStatusMailbox,
841 editor_text: &crate::editor_text::EditorTextMailbox,
842 action: RuntimeActionId,
843 args: serde_json::Value,
844) -> Result<serde_json::Value, String> {
845 match action {
846 RuntimeActionId::SendMessage => host.send_message(args).await,
847 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
848 RuntimeActionId::AppendEntry => host.append_entry(args).await,
849 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
850 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
851 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
852 RuntimeActionId::SetModel => host.set_model(args).await,
853 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
854 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
855 RuntimeActionId::Compact => host.compact(args).await,
856 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
857 RuntimeActionId::NewSession => host.new_session(args).await,
858 RuntimeActionId::Fork => host.fork(args).await,
859 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
860 RuntimeActionId::SwitchSession => host.switch_session(args).await,
861 RuntimeActionId::Reload => host.reload(args).await,
862 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
863 RuntimeActionId::UiDialog => ui_dialog.handle(args),
864 RuntimeActionId::SetStatus => ext_status.handle(args),
867 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
868 RuntimeActionId::SetEditorText => editor_text.handle(args),
871 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
872 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
873 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
874 RuntimeActionId::StdioLifecycle => {
875 Err("stdio lifecycle requires a plugin context".to_owned())
876 }
877 }
878}
879
880pub extern "C" fn trampoline_runtime_action(
904 action_id: u32,
905 args_json: StbStringRef,
906 out: *mut StbString,
907 user_data: *mut c_void,
908) -> i32 {
909 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
910 run_action(
911 action_id,
912 u32::from(RuntimeActionId::ChildAgent),
914 args_json,
915 out,
916 user_data,
917 )
918 }));
919 match outcome {
920 Ok(rc) => rc,
921 Err(_) => {
922 tracing::error!("runtime_action trampoline panicked — reporting host error");
926 if !out.is_null() {
927 unsafe {
928 *out = StbString::from_string(
929 r#"{"error":"runtime_action panicked"}"#.to_string(),
930 );
931 }
932 }
933 1
934 }
935 }
936}
937
938fn run_action(
940 action_id: u32,
941 max_action_id: u32,
942 args_json: StbStringRef,
943 out: *mut StbString,
944 user_data: *mut c_void,
945) -> i32 {
946 let action = match RuntimeActionId::try_from(action_id) {
950 Ok(action) if action_id <= max_action_id => action,
951 Ok(_) | Err(_) => {
952 if !out.is_null() {
953 let json = serde_json::json!({
954 "error": format!("unknown runtime action id {action_id}")
955 })
956 .to_string();
957 unsafe {
961 *out = StbString::from_string(json);
962 }
963 }
964 return 2;
965 }
966 };
967
968 if user_data.is_null() {
969 return -1;
970 }
971 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
974 let invocation_thread = invocation.is_some();
975 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
976 let Some(bridge) = bridge else {
977 if !out.is_null() {
978 unsafe {
980 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
981 }
982 }
983 return 1;
984 };
985
986 if !bridge.is_active() {
994 if out.is_null() {
995 return 1;
996 }
997 let json = serde_json::json!({
998 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
999 })
1000 .to_string();
1001 unsafe {
1002 *out = StbString::from_string(json);
1003 }
1004 return 1;
1005 }
1006
1007 let args_str = unsafe { args_json.as_str() };
1010 let mut args: serde_json::Value = if args_str.is_empty() {
1011 serde_json::Value::Object(serde_json::Map::new())
1012 } else {
1013 serde_json::from_str(args_str)
1014 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1015 };
1016
1017 let runtime_key = args
1018 .as_object_mut()
1019 .and_then(|args| args.remove("__rpiRuntimeKey"));
1020
1021 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1026 let host = Arc::clone(&bridge.host);
1030 let ui_dialog = bridge.ui_dialog.clone();
1031 let ext_status = bridge.ext_status.clone();
1032 let editor_text = bridge.editor_text.clone();
1033 let reload_cb = bridge.reload.clone();
1034 let execution_context = bridge.execution_context.clone();
1035 if action == RuntimeActionId::StdioLifecycle {
1036 let result = bridge.stdio.handle(
1037 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1038 args,
1039 );
1040 let _ = tx.send(result);
1041 } else if action == RuntimeActionId::UiComponent {
1042 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1043 let _owners = bridge
1044 .component_owners
1045 .lock()
1046 .unwrap_or_else(|e| e.into_inner());
1047 let result = if bridge.is_active() {
1048 ext_status.component(owner, args)
1049 } else {
1050 Err("stale plugin bridge".into())
1051 };
1052 let _ = tx.send(result);
1053 } else if action == RuntimeActionId::ChildAgent {
1054 let cleanup = matches!(
1059 args.get("op").and_then(serde_json::Value::as_str),
1060 Some("poll" | "cancel" | "dispose")
1061 );
1062 let allowed = bridge
1063 .invocation
1064 .as_ref()
1065 .is_some_and(|(child, key, signal)| {
1066 !child
1067 && (cleanup || !signal.is_cancelled())
1068 && (invocation_thread
1069 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1070 == Some(key.as_str()))
1071 });
1072 if !allowed {
1073 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1074 } else {
1075 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1076 let signal = bridge
1077 .invocation
1078 .as_ref()
1079 .expect("authorized invocation")
1080 .2
1081 .clone();
1082 bridge.runtime.spawn(async move {
1083 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1084 signal,
1085 host.child_agent(owner, args),
1086 )
1087 .await;
1088 let _ = tx.send(result);
1089 });
1090 }
1091 } else if action == RuntimeActionId::SetStatus {
1092 std::thread::spawn(move || {
1097 let _ = tx.send(ext_status.handle(args));
1098 });
1099 } else if action == RuntimeActionId::SetEditorText {
1100 std::thread::spawn(move || {
1103 let _ = tx.send(editor_text.handle(args));
1104 });
1105 } else if action == RuntimeActionId::UiDialog {
1106 std::thread::spawn(move || {
1112 let _ = tx.send(ui_dialog.handle(args));
1113 });
1114 } else {
1115 bridge.runtime.spawn(async move {
1116 let mut r = if action == RuntimeActionId::Reload {
1117 if let Some(cb) = reload_cb {
1118 cb().await;
1119 Ok(serde_json::Value::Null)
1120 } else {
1121 host.reload(args).await
1122 }
1123 } else {
1124 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1125 };
1126 if action == RuntimeActionId::GetExecutionContext {
1127 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1128 (&mut r, execution_context)
1129 {
1130 if let Some(object) = context.as_object_mut() {
1131 object.extend(fields);
1132 }
1133 }
1134 }
1135 let _ = tx.send(r);
1138 });
1139 }
1140
1141 let received = if Handle::try_current()
1147 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1148 {
1149 tokio::task::block_in_place(|| rx.recv())
1150 } else {
1151 rx.recv()
1152 };
1153 let result = match received {
1154 Ok(r) => r,
1155 Err(_) => {
1156 return -2;
1161 }
1162 };
1163
1164 if out.is_null() {
1167 return match result {
1169 Ok(_) => 0,
1170 Err(_) => 1,
1171 };
1172 }
1173
1174 let (rc, payload) = match result {
1175 Ok(value) => {
1176 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1177 (0, json)
1178 }
1179 Err(msg) => {
1180 let json = serde_json::json!({ "error": msg }).to_string();
1181 (1, json)
1182 }
1183 };
1184 unsafe {
1190 *out = StbString::from_string(payload);
1191 }
1192 rc
1193}
1194
1195#[cfg(test)]
1204mod tests {
1205 use super::*;
1206 use std::sync::Mutex;
1207
1208 #[tokio::test(flavor = "current_thread")]
1209 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1210 let master = ActionBridge::new(
1211 Handle::current(),
1212 Arc::new(MockHost {
1213 prompt: String::new(),
1214 saw: Mutex::new(Vec::new()),
1215 }),
1216 );
1217 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1218 let owner = crate::HostApi::with_action_bridge(
1219 "owner",
1220 crate::ExtensionRegistry::new(),
1221 diag.clone(),
1222 master.clone(),
1223 );
1224 let other = crate::HostApi::with_action_bridge(
1225 "other",
1226 crate::ExtensionRegistry::new(),
1227 diag,
1228 master.clone(),
1229 );
1230 let vtable = owner.build_vtable();
1231 let claim = r#"{"ownsStdio":true}"#;
1232 {
1233 let _registration = crate::enter_current_api(&owner);
1234 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1235 }
1236 let other_vtable = other.build_vtable();
1237 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1238 let mut out = StbString::empty();
1239 assert_eq!(
1241 (other_vtable.runtime_action)(
1242 RuntimeActionId::StdioLifecycle as u32,
1243 finish,
1244 &mut out,
1245 other_vtable.user_data
1246 ),
1247 1
1248 );
1249 unsafe { (other_vtable.free_string)(out) };
1251 assert_eq!(
1252 (vtable.runtime_action)(
1253 RuntimeActionId::StdioLifecycle as u32,
1254 finish,
1255 &mut out,
1256 vtable.user_data
1257 ),
1258 0
1259 );
1260 unsafe { (vtable.free_string)(out) };
1262 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1263 {
1264 let _registration = crate::enter_current_api(&other);
1265 assert_ne!(
1266 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1267 0
1268 );
1269 }
1270 assert!(master
1271 .stdio_lifecycle()
1272 .validate()
1273 .unwrap_err()
1274 .contains("owner"));
1275 }
1276
1277 struct MockHost {
1282 prompt: String,
1283 saw: Mutex<Vec<RuntimeActionId>>,
1284 }
1285
1286 #[test]
1287 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1288 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1289 if std::env::var_os(CHILD).is_some() {
1290 let runtime = tokio::runtime::Builder::new_multi_thread()
1291 .worker_threads(1)
1292 .enable_all()
1293 .build()
1294 .unwrap();
1295 runtime.block_on(async {
1296 tokio::spawn(async {
1299 let host = Arc::new(MockHost {
1300 prompt: "worker callback".into(),
1301 saw: Mutex::new(Vec::new()),
1302 });
1303 let bridge = ActionBridge::new(Handle::current(), host);
1304 let mut out = StbString::empty();
1305 assert_eq!(
1306 trampoline_runtime_action(
1307 RuntimeActionId::GetSystemPrompt.into(),
1308 StbStringRef::from_str("{}"),
1309 &mut out,
1310 bridge.user_data(),
1311 ),
1312 0
1313 );
1314 let text = unsafe {
1316 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1317 out,
1318 Some(crate::host_free_string),
1319 )
1320 }
1321 .to_string_lossy();
1322 assert_eq!(
1323 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1324 "worker callback"
1325 );
1326 })
1327 .await
1328 .unwrap();
1329 });
1330 return;
1331 }
1332 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1335 .args([
1336 "--exact",
1337 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1338 "--nocapture",
1339 ])
1340 .env(CHILD, "1")
1341 .stdout(std::process::Stdio::piped())
1342 .stderr(std::process::Stdio::piped())
1343 .spawn()
1344 .unwrap();
1345 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1346 while child.try_wait().unwrap().is_none() {
1347 if std::time::Instant::now() >= deadline {
1348 let _ = child.kill();
1349 let _ = child.wait();
1350 panic!("runtime action blocked the worker needed by its own dispatch");
1351 }
1352 std::thread::sleep(std::time::Duration::from_millis(10));
1353 }
1354 let output = child.wait_with_output().unwrap();
1355 assert!(
1356 output.status.success(),
1357 "{}\n{}",
1358 String::from_utf8_lossy(&output.stdout),
1359 String::from_utf8_lossy(&output.stderr)
1360 );
1361 }
1362
1363 #[async_trait::async_trait]
1364 impl RuntimeActionHost for MockHost {
1365 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1366 unreachable!("not under test")
1367 }
1368 async fn send_user_message(
1369 &self,
1370 _: serde_json::Value,
1371 ) -> Result<serde_json::Value, String> {
1372 unreachable!("not under test")
1373 }
1374 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1375 unreachable!("not under test")
1376 }
1377 async fn set_session_name(
1378 &self,
1379 _: serde_json::Value,
1380 ) -> Result<serde_json::Value, String> {
1381 unreachable!("not under test")
1382 }
1383 async fn get_active_tools(
1384 &self,
1385 _: serde_json::Value,
1386 ) -> Result<serde_json::Value, String> {
1387 unreachable!("not under test")
1388 }
1389 async fn set_active_tools(
1390 &self,
1391 _: serde_json::Value,
1392 ) -> Result<serde_json::Value, String> {
1393 unreachable!("not under test")
1394 }
1395 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1396 unreachable!("not under test")
1397 }
1398 async fn get_thinking_level(
1399 &self,
1400 _: serde_json::Value,
1401 ) -> Result<serde_json::Value, String> {
1402 unreachable!("not under test")
1403 }
1404 async fn set_thinking_level(
1405 &self,
1406 _: serde_json::Value,
1407 ) -> Result<serde_json::Value, String> {
1408 unreachable!("not under test")
1409 }
1410 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1411 unreachable!("not under test")
1412 }
1413 async fn get_system_prompt(
1414 &self,
1415 _: serde_json::Value,
1416 ) -> Result<serde_json::Value, String> {
1417 self.saw
1418 .lock()
1419 .unwrap()
1420 .push(RuntimeActionId::GetSystemPrompt);
1421 Ok(serde_json::json!({ "prompt": self.prompt }))
1422 }
1423 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1424 unreachable!("not under test")
1425 }
1426 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1427 unreachable!("not under test")
1428 }
1429 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1430 unreachable!("not under test")
1431 }
1432 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1433 unreachable!("not under test")
1434 }
1435 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1436 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1437 Ok(serde_json::Value::Null)
1438 }
1439 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1440 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1441 Ok(serde_json::json!({ "value": true }))
1442 }
1443 }
1444
1445 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1449 async fn trampoline_round_trips_get_system_prompt() {
1450 let host = Arc::new(MockHost {
1451 prompt: "hello from host".to_string(),
1452 saw: Mutex::new(Vec::new()),
1453 });
1454 let host_for_assert = Arc::clone(&host);
1455 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1456 let runtime = tokio::runtime::Handle::current();
1457 let bridge = ActionBridge::new(runtime, host_dyn);
1458 let user_data = bridge.user_data();
1459
1460 let args_str = "{}";
1462 let args_ref = StbStringRef::from_str(args_str);
1463
1464 let mut out = StbString::empty();
1465 let rc = trampoline_runtime_action(
1466 RuntimeActionId::GetSystemPrompt.into(),
1467 args_ref,
1468 &mut out as *mut StbString,
1469 user_data,
1470 );
1471 assert_eq!(rc, 0, "success return code");
1472
1473 let json_text = unsafe {
1476 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1477 }
1478 .to_string_lossy();
1479 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1480 assert_eq!(parsed["prompt"], "hello from host");
1481
1482 let saw = host_for_assert.saw.lock().unwrap().clone();
1485 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1486 }
1487
1488 #[tokio::test]
1494 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1495 let host = Arc::new(MockHost {
1496 prompt: String::new(),
1497 saw: Mutex::new(Vec::new()),
1498 });
1499 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1500 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1501 let mailbox = bridge.editor_text_mailbox();
1502 let user_data = bridge.user_data();
1503
1504 let args_ref =
1505 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1506 let mut out = StbString::empty();
1507 let rc = trampoline_runtime_action(
1508 RuntimeActionId::SetEditorText.into(),
1509 args_ref,
1510 &mut out as *mut StbString,
1511 user_data,
1512 );
1513 assert_eq!(rc, 0, "success return code");
1514
1515 let parsed: serde_json::Value = serde_json::from_str(
1517 &unsafe {
1518 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1519 }
1520 .to_string_lossy(),
1521 )
1522 .expect("valid json");
1523 assert_eq!(parsed["ok"], serde_json::json!(true));
1524 assert_eq!(parsed["chars"], serde_json::json!(10));
1525
1526 let edit = mailbox.take_pending().expect("queued edit");
1528 assert_eq!(edit.text, "from voice");
1529 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1530 assert_eq!(edit.auto_send_ms, Some(2000));
1531 assert!(mailbox.take_pending().is_none());
1532 }
1533
1534 #[tokio::test]
1535 async fn trampoline_null_user_data_returns_minus_one() {
1536 let args_ref = StbStringRef::from_str("{}");
1537 let mut out = StbString::empty();
1538 let rc = trampoline_runtime_action(
1539 RuntimeActionId::GetSystemPrompt.into(),
1540 args_ref,
1541 &mut out as *mut StbString,
1542 std::ptr::null_mut(),
1543 );
1544 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1545 }
1546
1547 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1548 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1549 use std::sync::atomic::{AtomicUsize, Ordering};
1553 let reload_calls = Arc::new(AtomicUsize::new(0));
1554 let reload_calls_for_cb = Arc::clone(&reload_calls);
1555 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1556 Arc::new(move || {
1557 let c = Arc::clone(&reload_calls_for_cb);
1558 Box::pin(async move {
1559 c.fetch_add(1, Ordering::SeqCst);
1560 })
1561 });
1562 let host = Arc::new(MockHost {
1563 prompt: String::new(),
1564 saw: Mutex::new(Vec::new()),
1565 });
1566 let host_for_assert = Arc::clone(&host);
1567 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1568 let runtime = tokio::runtime::Handle::current();
1569 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1570 let user_data = bridge.user_data();
1571
1572 let args_ref = StbStringRef::from_str("{}");
1573 let mut out = StbString::empty();
1574 let rc = trampoline_runtime_action(
1575 RuntimeActionId::Reload.into(),
1576 args_ref,
1577 &mut out as *mut StbString,
1578 user_data,
1579 );
1580 assert_eq!(rc, 0);
1581 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1583 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1584 drop(unsafe {
1586 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1587 });
1588 }
1589
1590 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1595 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1596 let host = Arc::new(MockHost {
1597 prompt: String::new(),
1598 saw: Mutex::new(Vec::new()),
1599 });
1600 let host_for_assert = Arc::clone(&host);
1601 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1602 let runtime = tokio::runtime::Handle::current();
1603 let bridge = ActionBridge::new(runtime, host_dyn);
1604 let user_data = bridge.user_data();
1605
1606 bridge.invalidate();
1608 assert!(!bridge.is_active());
1609
1610 let args_ref = StbStringRef::from_str("{}");
1611 let mut out = StbString::empty();
1612 let rc = trampoline_runtime_action(
1613 RuntimeActionId::GetSystemPrompt.into(),
1614 args_ref,
1615 &mut out as *mut StbString,
1616 user_data,
1617 );
1618 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1620 let json_text = unsafe {
1622 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1623 }
1624 .to_string_lossy();
1625 assert!(
1626 json_text.contains("stale"),
1627 "stale-bridge error payload: {json_text}"
1628 );
1629
1630 assert!(
1631 host_for_assert.saw.lock().unwrap().is_empty(),
1632 "host dispatch must NOT run on a stale bridge"
1633 );
1634 }
1635
1636 #[tokio::test]
1637 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1638 let host = Arc::new(MockHost {
1639 prompt: String::new(),
1640 saw: Mutex::new(Vec::new()),
1641 });
1642 let host_for_assert = Arc::clone(&host);
1643 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1644 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1645 let user_data = bridge.user_data();
1646
1647 let mut out = StbString::empty();
1648 let rc = trampoline_runtime_action(
1649 0xFFFF_FFFE,
1650 StbStringRef::from_str("{}"),
1651 &mut out,
1652 user_data,
1653 );
1654
1655 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1656 let payload: serde_json::Value = serde_json::from_str(
1657 &unsafe {
1658 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1659 }
1660 .to_string_lossy(),
1661 )
1662 .expect("structured error JSON");
1663 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1664
1665 assert!(
1666 host_for_assert.saw.lock().unwrap().is_empty(),
1667 "unknown action id must not reach host dispatch"
1668 );
1669 }
1670
1671 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1672 async fn v2_trampoline_accepts_new_action() {
1673 let host = Arc::new(MockHost {
1674 prompt: "v2".to_string(),
1675 saw: Mutex::new(Vec::new()),
1676 });
1677 let host_for_assert = Arc::clone(&host);
1678 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1679 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1680 let user_data = bridge.user_data();
1681
1682 let mut v2_out = StbString::empty();
1683 assert_eq!(
1684 trampoline_runtime_action(
1685 RuntimeActionId::GetCliFlag.into(),
1686 StbStringRef::from_str(r#"{"name":"flag"}"#),
1687 &mut v2_out,
1688 user_data,
1689 ),
1690 0
1691 );
1692 drop(unsafe {
1694 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1695 });
1696
1697 assert_eq!(
1698 *host_for_assert.saw.lock().unwrap(),
1699 vec![RuntimeActionId::GetCliFlag]
1700 );
1701 }
1702
1703 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1708 async fn reload_mailbox_signals_installed_receiver() {
1709 let mailbox = ReloadMailbox::new();
1710 assert!(matches!(mailbox.signal(), Err(())));
1712
1713 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1714 mailbox.install(tx);
1715 let cb = reload_callback_from_mailbox(mailbox.clone());
1716 cb().await;
1717 assert_eq!(
1718 rx.recv().await,
1719 Some(()),
1720 "installed receiver saw the signal"
1721 );
1722 mailbox.clear();
1723 }
1724
1725 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1728 serde_json::json!({
1729 "op": "open",
1730 "requestId": id,
1731 "toolCallId": tool_call,
1732 "ui": {"kind": "selector", "questions": [
1733 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1734 ]},
1735 })
1736 }
1737
1738 #[test]
1741 fn ui_dialog_open_requires_an_attached_ui() {
1742 let mailbox = UiDialogMailbox::new();
1743 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1744 assert!(error.contains("interactive UI"), "got: {error}");
1745 }
1746
1747 #[test]
1748 fn ui_dialog_open_poll_answer_round_trip() {
1749 let mailbox = UiDialogMailbox::new();
1750 mailbox.attach();
1751
1752 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1753 assert_eq!(opened["status"], "pending");
1754 assert_eq!(opened["toolCallId"], "c1");
1755
1756 let request = mailbox.take_pending().expect("request visible to TUI");
1757 assert_eq!(request.request_id, "r1");
1758 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1759 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1761 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1762
1763 mailbox
1764 .respond("r1", serde_json::json!("7890"))
1765 .expect("respond");
1766 let answered = mailbox.poll("r1").unwrap();
1767 assert_eq!(answered["status"], "answered");
1768 assert_eq!(answered["answer"], "7890");
1769 assert!(mailbox.poll("r1").is_err());
1771 }
1772
1773 #[test]
1774 fn ui_dialog_cancel_is_reported_once() {
1775 let mailbox = UiDialogMailbox::new();
1776 mailbox.attach();
1777 mailbox.handle(open_args("r1", "c1")).unwrap();
1778 mailbox.cancel("r1").unwrap();
1779 let cancelled = mailbox.poll("r1").unwrap();
1780 assert_eq!(cancelled["status"], "cancelled");
1781 assert!(mailbox.poll("r1").is_err());
1782 }
1783
1784 #[test]
1785 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1786 let mailbox = UiDialogMailbox::new();
1787 mailbox.attach();
1788 mailbox.handle(open_args("r1", "c1")).unwrap();
1789 mailbox.handle(open_args("r2", "c2")).unwrap();
1790 mailbox.cancel_tool_call("c1");
1791 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1792 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1793 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1794 assert_eq!(
1795 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1796 "cancelled"
1797 );
1798 assert!(mailbox.take_pending().is_none());
1799 }
1800
1801 #[test]
1802 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1803 let mailbox = UiDialogMailbox::new();
1804 mailbox.attach();
1805 mailbox.handle(open_args("r1", "c1")).unwrap();
1806 mailbox.handle(open_args("r2", "c2")).unwrap();
1807
1808 let first = mailbox.take_pending().unwrap().request_id;
1809 let second = mailbox.take_pending().unwrap().request_id;
1810 assert_ne!(first, second);
1811
1812 mailbox
1814 .respond(&second, serde_json::json!("second"))
1815 .unwrap();
1816 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1817 let second_answer = mailbox.poll(&second).unwrap();
1818 assert_eq!(second_answer["answer"], "second");
1819
1820 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1821 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1822 }
1823
1824 #[test]
1825 fn ui_dialog_detach_cancels_outstanding() {
1826 let mailbox = UiDialogMailbox::new();
1827 mailbox.attach();
1828 mailbox.handle(open_args("r1", "c1")).unwrap();
1829 mailbox.detach();
1830 assert!(!mailbox.is_attached());
1831 assert_eq!(mailbox.pending_len(), 0);
1832 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1834 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1835 }
1836
1837 #[test]
1838 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1839 let mailbox = UiDialogMailbox::new();
1840 mailbox.attach();
1841 mailbox.handle(open_args("r1", "c1")).unwrap();
1842 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1843 assert_eq!(again["status"], "pending");
1844 assert_eq!(mailbox.pending_len(), 1);
1845 mailbox.take_pending().unwrap();
1846 assert!(mailbox.take_pending().is_none());
1847 }
1848
1849 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1853 async fn v2_dispatch_routes_ui_dialog_action_17() {
1854 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1855 prompt: String::new(),
1856 saw: Mutex::new(Vec::new()),
1857 });
1858 let bridge = ActionBridge::with_ui_dialog(
1859 tokio::runtime::Handle::current(),
1860 host,
1861 UiDialogMailbox::new(),
1862 );
1863 let user_data = bridge.user_data();
1864
1865 let mut out = StbString::empty();
1866 let rc = trampoline_runtime_action(
1867 17,
1868 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1869 &mut out,
1870 user_data,
1871 );
1872 assert_eq!(rc, 1);
1873 let payload: serde_json::Value = serde_json::from_str(
1874 &unsafe {
1875 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1876 }
1877 .to_string_lossy(),
1878 )
1879 .expect("structured error JSON");
1880 assert!(
1881 payload["error"]
1882 .as_str()
1883 .is_some_and(|error| error.contains("interactive UI")),
1884 "unexpected payload: {payload}"
1885 );
1886
1887 let mut out = StbString::empty();
1890 assert_eq!(
1891 trampoline_runtime_action(25, StbStringRef::from_str("{}"), &mut out, user_data),
1892 2
1893 );
1894 drop(unsafe {
1896 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1897 });
1898 }
1899
1900 #[tokio::test(flavor = "current_thread")]
1901 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1902 let host = Arc::new(MockHost {
1903 prompt: String::new(),
1904 saw: Mutex::new(Vec::new()),
1905 });
1906 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1907 let a = root.for_plugin("a");
1908 let b = root.for_plugin("b");
1909 let mailbox = root.extension_status_mailbox();
1910 for plugin in [&a, &b] {
1911 let mut out = StbString::empty();
1912 assert_eq!(
1913 trampoline_runtime_action(
1914 22,
1915 StbStringRef::from_str(
1916 r#"{"op":"register","id":"same","slot":"footer.after"}"#
1917 ),
1918 &mut out,
1919 plugin.user_data()
1920 ),
1921 0
1922 );
1923 drop(unsafe {
1925 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1926 });
1927 }
1928 assert_eq!(mailbox.panels().len(), 2);
1929 drop(a);
1930 assert_eq!(mailbox.panels().len(), 1);
1931 root.invalidate();
1932 assert!(mailbox.panels().is_empty());
1933 }
1934
1935 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1938 async fn v2_dispatch_routes_set_status_action_18() {
1939 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1940 prompt: String::new(),
1941 saw: Mutex::new(Vec::new()),
1942 });
1943 let status = crate::status::ExtensionStatusMailbox::new();
1944 let bridge = ActionBridge::with_reload_and_ui(
1945 tokio::runtime::Handle::current(),
1946 host,
1947 Arc::new(|| Box::pin(async {})),
1948 UiDialogMailbox::new(),
1949 status.clone(),
1950 crate::editor_text::EditorTextMailbox::new(),
1951 );
1952 let user_data = bridge.user_data();
1953
1954 let mut out = StbString::empty();
1955 let rc = trampoline_runtime_action(
1956 18,
1957 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1958 &mut out,
1959 user_data,
1960 );
1961 assert_eq!(rc, 0);
1962 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1963 drop(unsafe {
1965 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1966 });
1967
1968 let mut out = StbString::empty();
1970 assert_eq!(
1971 trampoline_runtime_action(
1972 18,
1973 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1974 &mut out,
1975 user_data
1976 ),
1977 0
1978 );
1979 assert_eq!(status.text(), "");
1980 drop(unsafe {
1982 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1983 });
1984
1985 let mut out = StbString::empty();
1987 assert_eq!(
1988 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1989 1
1990 );
1991 drop(unsafe {
1993 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1994 });
1995 }
1996}
1997
1998impl Drop for ActionBridge {
1999 fn drop(&mut self) {
2000 bridges()
2001 .lock()
2002 .unwrap_or_else(|e| e.into_inner())
2003 .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2004 if self.invocation.is_some() {
2005 return;
2006 }
2007 if let Some((owner, _)) = &self.plugin_identity {
2008 self.ext_status.remove_owner(*owner);
2009 self.host.cancel_child_agents(*owner);
2010 }
2011 }
2012}
2013
2014#[cfg(test)]
2015mod runtime_tests;