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 mut id = NEXT.load(Ordering::Relaxed);
538 loop {
539 let next = id.checked_add(1).expect("runtime handle space exhausted");
540 match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
541 Ok(_) => break,
542 Err(current) => id = current,
543 }
544 }
545 registry.insert(id, Arc::downgrade(self));
546 id as *mut c_void
547 }
548 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
549 bridges()
550 .lock()
551 .unwrap_or_else(|e| e.into_inner())
552 .get(&id)
553 .and_then(Weak::upgrade)
554 }
555 pub(crate) fn for_invocation(
556 &self,
557 child: bool,
558 signal: tokio_util::sync::CancellationToken,
559 ) -> Arc<Self> {
560 Arc::new(Self {
561 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
562 execution_context: rpi_agent::tool_execution_scope::execution_context(),
563 stdio: self.stdio.clone(),
564 plugin_identity: self.plugin_identity.clone(),
565 component_owners: self.component_owners.clone(),
566 runtime: self.runtime.clone(),
567 host: self.host.clone(),
568 reload: self.reload.clone(),
569 ui_dialog: self.ui_dialog.clone(),
570 ext_status: self.ext_status.clone(),
571 editor_text: self.editor_text.clone(),
572 active: self.active.clone(),
573 })
574 }
575 pub(crate) fn invocation_key(&self) -> Option<&str> {
576 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
577 }
578 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
579 InvocationGuard {
580 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
581 _thread_bound: std::marker::PhantomData,
582 }
583 }
584 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
587 Arc::new(Self {
588 invocation: None,
589 execution_context: None,
590 stdio: crate::StdioLifecycle::default(),
591 plugin_identity: None,
592 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
593 runtime,
594 host,
595 reload: None,
596 ui_dialog: UiDialogMailbox::new(),
597 ext_status: crate::status::ExtensionStatusMailbox::new(),
598 editor_text: crate::editor_text::EditorTextMailbox::new(),
599 active: Arc::new(AtomicBool::new(true)),
600 })
601 }
602
603 pub fn with_reload(
606 runtime: Handle,
607 host: Arc<dyn RuntimeActionHost>,
608 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
609 ) -> Arc<Self> {
610 Self::with_reload_and_ui(
611 runtime,
612 host,
613 reload,
614 UiDialogMailbox::new(),
615 crate::status::ExtensionStatusMailbox::new(),
616 crate::editor_text::EditorTextMailbox::new(),
617 )
618 }
619
620 pub fn with_ui_dialog(
622 runtime: Handle,
623 host: Arc<dyn RuntimeActionHost>,
624 ui_dialog: UiDialogMailbox,
625 ) -> Arc<Self> {
626 Arc::new(Self {
627 invocation: None,
628 execution_context: None,
629 stdio: crate::StdioLifecycle::default(),
630 plugin_identity: None,
631 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
632 runtime,
633 host,
634 reload: None,
635 ui_dialog,
636 ext_status: crate::status::ExtensionStatusMailbox::new(),
637 editor_text: crate::editor_text::EditorTextMailbox::new(),
638 active: Arc::new(AtomicBool::new(true)),
639 })
640 }
641
642 pub fn with_reload_and_ui(
644 runtime: Handle,
645 host: Arc<dyn RuntimeActionHost>,
646 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
647 ui_dialog: UiDialogMailbox,
648 ext_status: crate::status::ExtensionStatusMailbox,
649 editor_text: crate::editor_text::EditorTextMailbox,
650 ) -> Arc<Self> {
651 Arc::new(Self {
652 invocation: None,
653 execution_context: None,
654 stdio: crate::StdioLifecycle::default(),
655 plugin_identity: None,
656 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
657 runtime,
658 host,
659 reload: Some(reload),
660 ui_dialog,
661 ext_status,
662 editor_text,
663 active: Arc::new(AtomicBool::new(true)),
664 })
665 }
666
667 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
668 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
669 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
670 self.component_owners
671 .lock()
672 .unwrap_or_else(|e| e.into_inner())
673 .push(owner);
674 Arc::new(Self {
675 invocation: None,
676 execution_context: None,
677 component_owners: self.component_owners.clone(),
678 stdio: self.stdio.clone(),
679 plugin_identity: Some((owner, name.to_owned())),
680 runtime: self.runtime.clone(),
681 host: self.host.clone(),
682 reload: self.reload.clone(),
683 ui_dialog: self.ui_dialog.clone(),
684 ext_status: self.ext_status.clone(),
685 editor_text: self.editor_text.clone(),
686 active: self.active.clone(),
687 })
688 }
689
690 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
691 self.stdio.clone()
692 }
693
694 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
695 let (id, name) = self
696 .plugin_identity
697 .as_ref()
698 .ok_or("missing plugin identity")?;
699 self.stdio.claim(*id, name)
700 }
701
702 pub(crate) fn release_stdio_claim(&self) {
703 if let Some((id, _)) = &self.plugin_identity {
704 self.stdio.release_failed_registration(*id);
705 self.ext_status.remove_owner(*id);
706 }
707 }
708
709 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
711 self.ui_dialog.clone()
712 }
713
714 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
716 self.ext_status.clone()
717 }
718
719 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
721 self.editor_text.clone()
722 }
723
724 pub fn invalidate(&self) {
729 let owners = self
730 .component_owners
731 .lock()
732 .unwrap_or_else(|e| e.into_inner());
733 self.active.store(false, Ordering::SeqCst);
734 for owner in owners.iter() {
735 self.ext_status.remove_owner(*owner);
736 self.host.cancel_child_agents(*owner);
737 }
738 }
739
740 pub fn is_active(&self) -> bool {
742 self.active.load(Ordering::SeqCst)
743 }
744
745 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
752 Arc::clone(&self.host)
753 }
754}
755
756#[derive(Clone)]
769pub struct ReloadMailbox {
770 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
771}
772
773impl Default for ReloadMailbox {
774 fn default() -> Self {
775 Self {
776 tx: Arc::new(std::sync::Mutex::new(None)),
777 }
778 }
779}
780
781impl ReloadMailbox {
782 pub fn new() -> Self {
784 Self::default()
785 }
786
787 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
791 *self.tx.lock().unwrap() = Some(tx);
792 }
793
794 pub fn signal(&self) -> Result<(), ()> {
799 let g = self.tx.lock().unwrap();
800 match &*g {
801 Some(tx) => {
802 let _ = tx.send(());
803 Ok(())
804 }
805 None => Err(()),
806 }
807 }
808
809 pub fn clear(&self) {
811 *self.tx.lock().unwrap() = None;
812 }
813}
814
815pub fn reload_callback_from_mailbox(
822 mailbox: ReloadMailbox,
823) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
824 Arc::new(move || {
825 let m = mailbox.clone();
826 Box::pin(async move {
827 let _ = m.signal();
828 })
829 })
830}
831
832async fn dispatch(
843 host: &Arc<dyn RuntimeActionHost>,
844 ui_dialog: &UiDialogMailbox,
845 ext_status: &crate::status::ExtensionStatusMailbox,
846 editor_text: &crate::editor_text::EditorTextMailbox,
847 action: RuntimeActionId,
848 args: serde_json::Value,
849) -> Result<serde_json::Value, String> {
850 match action {
851 RuntimeActionId::SendMessage => host.send_message(args).await,
852 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
853 RuntimeActionId::AppendEntry => host.append_entry(args).await,
854 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
855 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
856 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
857 RuntimeActionId::SetModel => host.set_model(args).await,
858 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
859 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
860 RuntimeActionId::Compact => host.compact(args).await,
861 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
862 RuntimeActionId::NewSession => host.new_session(args).await,
863 RuntimeActionId::Fork => host.fork(args).await,
864 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
865 RuntimeActionId::SwitchSession => host.switch_session(args).await,
866 RuntimeActionId::Reload => host.reload(args).await,
867 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
868 RuntimeActionId::UiDialog => ui_dialog.handle(args),
869 RuntimeActionId::SetStatus => ext_status.handle(args),
872 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
873 RuntimeActionId::SetEditorText => editor_text.handle(args),
876 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
877 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
878 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
879 RuntimeActionId::StdioLifecycle => {
880 Err("stdio lifecycle requires a plugin context".to_owned())
881 }
882 }
883}
884
885pub extern "C" fn trampoline_runtime_action(
909 action_id: u32,
910 args_json: StbStringRef,
911 out: *mut StbString,
912 user_data: *mut c_void,
913) -> i32 {
914 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
915 run_action(
916 action_id,
917 u32::from(RuntimeActionId::ChildAgent),
919 args_json,
920 out,
921 user_data,
922 )
923 }));
924 match outcome {
925 Ok(rc) => rc,
926 Err(_) => {
927 tracing::error!("runtime_action trampoline panicked — reporting host error");
931 if !out.is_null() {
932 unsafe {
933 *out = StbString::from_string(
934 r#"{"error":"runtime_action panicked"}"#.to_string(),
935 );
936 }
937 }
938 1
939 }
940 }
941}
942
943fn run_action(
945 action_id: u32,
946 max_action_id: u32,
947 args_json: StbStringRef,
948 out: *mut StbString,
949 user_data: *mut c_void,
950) -> i32 {
951 let action = match RuntimeActionId::try_from(action_id) {
955 Ok(action) if action_id <= max_action_id => action,
956 Ok(_) | Err(_) => {
957 if !out.is_null() {
958 let json = serde_json::json!({
959 "error": format!("unknown runtime action id {action_id}")
960 })
961 .to_string();
962 unsafe {
966 *out = StbString::from_string(json);
967 }
968 }
969 return 2;
970 }
971 };
972
973 if user_data.is_null() {
974 return -1;
975 }
976 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
979 let invocation_thread = invocation.is_some();
980 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
981 let Some(bridge) = bridge else {
982 if !out.is_null() {
983 unsafe {
985 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
986 }
987 }
988 return 1;
989 };
990
991 if !bridge.is_active() {
999 if out.is_null() {
1000 return 1;
1001 }
1002 let json = serde_json::json!({
1003 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1004 })
1005 .to_string();
1006 unsafe {
1007 *out = StbString::from_string(json);
1008 }
1009 return 1;
1010 }
1011
1012 let args_str = unsafe { args_json.as_str() };
1015 let mut args: serde_json::Value = if args_str.is_empty() {
1016 serde_json::Value::Object(serde_json::Map::new())
1017 } else {
1018 serde_json::from_str(args_str)
1019 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1020 };
1021
1022 let runtime_key = args
1023 .as_object_mut()
1024 .and_then(|args| args.remove("__rpiRuntimeKey"));
1025
1026 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1031 let host = Arc::clone(&bridge.host);
1035 let ui_dialog = bridge.ui_dialog.clone();
1036 let ext_status = bridge.ext_status.clone();
1037 let editor_text = bridge.editor_text.clone();
1038 let reload_cb = bridge.reload.clone();
1039 let execution_context = bridge.execution_context.clone();
1040 if action == RuntimeActionId::StdioLifecycle {
1041 let result = bridge.stdio.handle(
1042 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1043 args,
1044 );
1045 let _ = tx.send(result);
1046 } else if action == RuntimeActionId::UiComponent {
1047 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1048 let _owners = bridge
1049 .component_owners
1050 .lock()
1051 .unwrap_or_else(|e| e.into_inner());
1052 let result = if bridge.is_active() {
1053 ext_status.component(owner, args)
1054 } else {
1055 Err("stale plugin bridge".into())
1056 };
1057 let _ = tx.send(result);
1058 } else if action == RuntimeActionId::ChildAgent {
1059 let cleanup = matches!(
1064 args.get("op").and_then(serde_json::Value::as_str),
1065 Some("poll" | "cancel" | "dispose")
1066 );
1067 let allowed = bridge
1068 .invocation
1069 .as_ref()
1070 .is_some_and(|(child, key, signal)| {
1071 !child
1072 && (cleanup || !signal.is_cancelled())
1073 && (invocation_thread
1074 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1075 == Some(key.as_str()))
1076 });
1077 if !allowed {
1078 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1079 } else {
1080 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1081 let signal = bridge
1082 .invocation
1083 .as_ref()
1084 .expect("authorized invocation")
1085 .2
1086 .clone();
1087 bridge.runtime.spawn(async move {
1088 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1089 signal,
1090 host.child_agent(owner, args),
1091 )
1092 .await;
1093 let _ = tx.send(result);
1094 });
1095 }
1096 } else if action == RuntimeActionId::SetStatus {
1097 std::thread::spawn(move || {
1102 let _ = tx.send(ext_status.handle(args));
1103 });
1104 } else if action == RuntimeActionId::SetEditorText {
1105 std::thread::spawn(move || {
1108 let _ = tx.send(editor_text.handle(args));
1109 });
1110 } else if action == RuntimeActionId::UiDialog {
1111 std::thread::spawn(move || {
1117 let _ = tx.send(ui_dialog.handle(args));
1118 });
1119 } else {
1120 bridge.runtime.spawn(async move {
1121 let mut r = if action == RuntimeActionId::Reload {
1122 if let Some(cb) = reload_cb {
1123 cb().await;
1124 Ok(serde_json::Value::Null)
1125 } else {
1126 host.reload(args).await
1127 }
1128 } else {
1129 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1130 };
1131 if action == RuntimeActionId::GetExecutionContext {
1132 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1133 (&mut r, execution_context)
1134 {
1135 if let Some(object) = context.as_object_mut() {
1136 object.extend(fields);
1137 }
1138 }
1139 }
1140 let _ = tx.send(r);
1143 });
1144 }
1145
1146 let received = if Handle::try_current()
1152 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1153 {
1154 tokio::task::block_in_place(|| rx.recv())
1155 } else {
1156 rx.recv()
1157 };
1158 let result = match received {
1159 Ok(r) => r,
1160 Err(_) => {
1161 return -2;
1166 }
1167 };
1168
1169 if out.is_null() {
1172 return match result {
1174 Ok(_) => 0,
1175 Err(_) => 1,
1176 };
1177 }
1178
1179 let (rc, payload) = match result {
1180 Ok(value) => {
1181 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1182 (0, json)
1183 }
1184 Err(msg) => {
1185 let json = serde_json::json!({ "error": msg }).to_string();
1186 (1, json)
1187 }
1188 };
1189 unsafe {
1195 *out = StbString::from_string(payload);
1196 }
1197 rc
1198}
1199
1200#[cfg(test)]
1209mod tests {
1210 use super::*;
1211 use std::sync::Mutex;
1212
1213 #[tokio::test(flavor = "current_thread")]
1214 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1215 let master = ActionBridge::new(
1216 Handle::current(),
1217 Arc::new(MockHost {
1218 prompt: String::new(),
1219 saw: Mutex::new(Vec::new()),
1220 }),
1221 );
1222 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1223 let owner = crate::HostApi::with_action_bridge(
1224 "owner",
1225 crate::ExtensionRegistry::new(),
1226 diag.clone(),
1227 master.clone(),
1228 );
1229 let other = crate::HostApi::with_action_bridge(
1230 "other",
1231 crate::ExtensionRegistry::new(),
1232 diag,
1233 master.clone(),
1234 );
1235 let vtable = owner.build_vtable();
1236 let claim = r#"{"ownsStdio":true}"#;
1237 {
1238 let _registration = crate::enter_current_api(&owner);
1239 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1240 }
1241 let other_vtable = other.build_vtable();
1242 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1243 let mut out = StbString::empty();
1244 assert_eq!(
1246 (other_vtable.runtime_action)(
1247 RuntimeActionId::StdioLifecycle as u32,
1248 finish,
1249 &mut out,
1250 other_vtable.user_data
1251 ),
1252 1
1253 );
1254 unsafe { (other_vtable.free_string)(out) };
1256 assert_eq!(
1257 (vtable.runtime_action)(
1258 RuntimeActionId::StdioLifecycle as u32,
1259 finish,
1260 &mut out,
1261 vtable.user_data
1262 ),
1263 0
1264 );
1265 unsafe { (vtable.free_string)(out) };
1267 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1268 {
1269 let _registration = crate::enter_current_api(&other);
1270 assert_ne!(
1271 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1272 0
1273 );
1274 }
1275 assert!(master
1276 .stdio_lifecycle()
1277 .validate()
1278 .unwrap_err()
1279 .contains("owner"));
1280 }
1281
1282 struct MockHost {
1287 prompt: String,
1288 saw: Mutex<Vec<RuntimeActionId>>,
1289 }
1290
1291 #[test]
1292 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1293 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1294 if std::env::var_os(CHILD).is_some() {
1295 let runtime = tokio::runtime::Builder::new_multi_thread()
1296 .worker_threads(1)
1297 .enable_all()
1298 .build()
1299 .unwrap();
1300 runtime.block_on(async {
1301 tokio::spawn(async {
1304 let host = Arc::new(MockHost {
1305 prompt: "worker callback".into(),
1306 saw: Mutex::new(Vec::new()),
1307 });
1308 let bridge = ActionBridge::new(Handle::current(), host);
1309 let mut out = StbString::empty();
1310 assert_eq!(
1311 trampoline_runtime_action(
1312 RuntimeActionId::GetSystemPrompt.into(),
1313 StbStringRef::from_str("{}"),
1314 &mut out,
1315 bridge.user_data(),
1316 ),
1317 0
1318 );
1319 let text = unsafe {
1321 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1322 out,
1323 Some(crate::host_free_string),
1324 )
1325 }
1326 .to_string_lossy();
1327 assert_eq!(
1328 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1329 "worker callback"
1330 );
1331 })
1332 .await
1333 .unwrap();
1334 });
1335 return;
1336 }
1337 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1340 .args([
1341 "--exact",
1342 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1343 "--nocapture",
1344 ])
1345 .env(CHILD, "1")
1346 .stdout(std::process::Stdio::piped())
1347 .stderr(std::process::Stdio::piped())
1348 .spawn()
1349 .unwrap();
1350 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1351 while child.try_wait().unwrap().is_none() {
1352 if std::time::Instant::now() >= deadline {
1353 let _ = child.kill();
1354 let _ = child.wait();
1355 panic!("runtime action blocked the worker needed by its own dispatch");
1356 }
1357 std::thread::sleep(std::time::Duration::from_millis(10));
1358 }
1359 let output = child.wait_with_output().unwrap();
1360 assert!(
1361 output.status.success(),
1362 "{}\n{}",
1363 String::from_utf8_lossy(&output.stdout),
1364 String::from_utf8_lossy(&output.stderr)
1365 );
1366 }
1367
1368 #[async_trait::async_trait]
1369 impl RuntimeActionHost for MockHost {
1370 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1371 unreachable!("not under test")
1372 }
1373 async fn send_user_message(
1374 &self,
1375 _: serde_json::Value,
1376 ) -> Result<serde_json::Value, String> {
1377 unreachable!("not under test")
1378 }
1379 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1380 unreachable!("not under test")
1381 }
1382 async fn set_session_name(
1383 &self,
1384 _: serde_json::Value,
1385 ) -> Result<serde_json::Value, String> {
1386 unreachable!("not under test")
1387 }
1388 async fn get_active_tools(
1389 &self,
1390 _: serde_json::Value,
1391 ) -> Result<serde_json::Value, String> {
1392 unreachable!("not under test")
1393 }
1394 async fn set_active_tools(
1395 &self,
1396 _: serde_json::Value,
1397 ) -> Result<serde_json::Value, String> {
1398 unreachable!("not under test")
1399 }
1400 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1401 unreachable!("not under test")
1402 }
1403 async fn get_thinking_level(
1404 &self,
1405 _: serde_json::Value,
1406 ) -> Result<serde_json::Value, String> {
1407 unreachable!("not under test")
1408 }
1409 async fn set_thinking_level(
1410 &self,
1411 _: serde_json::Value,
1412 ) -> Result<serde_json::Value, String> {
1413 unreachable!("not under test")
1414 }
1415 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1416 unreachable!("not under test")
1417 }
1418 async fn get_system_prompt(
1419 &self,
1420 _: serde_json::Value,
1421 ) -> Result<serde_json::Value, String> {
1422 self.saw
1423 .lock()
1424 .unwrap()
1425 .push(RuntimeActionId::GetSystemPrompt);
1426 Ok(serde_json::json!({ "prompt": self.prompt }))
1427 }
1428 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1429 unreachable!("not under test")
1430 }
1431 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1432 unreachable!("not under test")
1433 }
1434 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1435 unreachable!("not under test")
1436 }
1437 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1438 unreachable!("not under test")
1439 }
1440 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1441 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1442 Ok(serde_json::Value::Null)
1443 }
1444 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1445 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1446 Ok(serde_json::json!({ "value": true }))
1447 }
1448 }
1449
1450 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1454 async fn trampoline_round_trips_get_system_prompt() {
1455 let host = Arc::new(MockHost {
1456 prompt: "hello from host".to_string(),
1457 saw: Mutex::new(Vec::new()),
1458 });
1459 let host_for_assert = Arc::clone(&host);
1460 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1461 let runtime = tokio::runtime::Handle::current();
1462 let bridge = ActionBridge::new(runtime, host_dyn);
1463 let user_data = bridge.user_data();
1464
1465 let args_str = "{}";
1467 let args_ref = StbStringRef::from_str(args_str);
1468
1469 let mut out = StbString::empty();
1470 let rc = trampoline_runtime_action(
1471 RuntimeActionId::GetSystemPrompt.into(),
1472 args_ref,
1473 &mut out as *mut StbString,
1474 user_data,
1475 );
1476 assert_eq!(rc, 0, "success return code");
1477
1478 let json_text = unsafe {
1481 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1482 }
1483 .to_string_lossy();
1484 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1485 assert_eq!(parsed["prompt"], "hello from host");
1486
1487 let saw = host_for_assert.saw.lock().unwrap().clone();
1490 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1491 }
1492
1493 #[tokio::test]
1499 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1500 let host = Arc::new(MockHost {
1501 prompt: String::new(),
1502 saw: Mutex::new(Vec::new()),
1503 });
1504 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1505 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1506 let mailbox = bridge.editor_text_mailbox();
1507 let user_data = bridge.user_data();
1508
1509 let args_ref =
1510 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1511 let mut out = StbString::empty();
1512 let rc = trampoline_runtime_action(
1513 RuntimeActionId::SetEditorText.into(),
1514 args_ref,
1515 &mut out as *mut StbString,
1516 user_data,
1517 );
1518 assert_eq!(rc, 0, "success return code");
1519
1520 let parsed: serde_json::Value = serde_json::from_str(
1522 &unsafe {
1523 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1524 }
1525 .to_string_lossy(),
1526 )
1527 .expect("valid json");
1528 assert_eq!(parsed["ok"], serde_json::json!(true));
1529 assert_eq!(parsed["chars"], serde_json::json!(10));
1530
1531 let edit = mailbox.take_pending().expect("queued edit");
1533 assert_eq!(edit.text, "from voice");
1534 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1535 assert_eq!(edit.auto_send_ms, Some(2000));
1536 assert!(mailbox.take_pending().is_none());
1537 }
1538
1539 #[tokio::test]
1540 async fn trampoline_null_user_data_returns_minus_one() {
1541 let args_ref = StbStringRef::from_str("{}");
1542 let mut out = StbString::empty();
1543 let rc = trampoline_runtime_action(
1544 RuntimeActionId::GetSystemPrompt.into(),
1545 args_ref,
1546 &mut out as *mut StbString,
1547 std::ptr::null_mut(),
1548 );
1549 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1550 }
1551
1552 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1553 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1554 use std::sync::atomic::{AtomicUsize, Ordering};
1558 let reload_calls = Arc::new(AtomicUsize::new(0));
1559 let reload_calls_for_cb = Arc::clone(&reload_calls);
1560 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1561 Arc::new(move || {
1562 let c = Arc::clone(&reload_calls_for_cb);
1563 Box::pin(async move {
1564 c.fetch_add(1, Ordering::SeqCst);
1565 })
1566 });
1567 let host = Arc::new(MockHost {
1568 prompt: String::new(),
1569 saw: Mutex::new(Vec::new()),
1570 });
1571 let host_for_assert = Arc::clone(&host);
1572 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1573 let runtime = tokio::runtime::Handle::current();
1574 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1575 let user_data = bridge.user_data();
1576
1577 let args_ref = StbStringRef::from_str("{}");
1578 let mut out = StbString::empty();
1579 let rc = trampoline_runtime_action(
1580 RuntimeActionId::Reload.into(),
1581 args_ref,
1582 &mut out as *mut StbString,
1583 user_data,
1584 );
1585 assert_eq!(rc, 0);
1586 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1588 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1589 drop(unsafe {
1591 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1592 });
1593 }
1594
1595 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1600 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1601 let host = Arc::new(MockHost {
1602 prompt: String::new(),
1603 saw: Mutex::new(Vec::new()),
1604 });
1605 let host_for_assert = Arc::clone(&host);
1606 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1607 let runtime = tokio::runtime::Handle::current();
1608 let bridge = ActionBridge::new(runtime, host_dyn);
1609 let user_data = bridge.user_data();
1610
1611 bridge.invalidate();
1613 assert!(!bridge.is_active());
1614
1615 let args_ref = StbStringRef::from_str("{}");
1616 let mut out = StbString::empty();
1617 let rc = trampoline_runtime_action(
1618 RuntimeActionId::GetSystemPrompt.into(),
1619 args_ref,
1620 &mut out as *mut StbString,
1621 user_data,
1622 );
1623 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1625 let json_text = unsafe {
1627 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1628 }
1629 .to_string_lossy();
1630 assert!(
1631 json_text.contains("stale"),
1632 "stale-bridge error payload: {json_text}"
1633 );
1634
1635 assert!(
1636 host_for_assert.saw.lock().unwrap().is_empty(),
1637 "host dispatch must NOT run on a stale bridge"
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1643 let host = Arc::new(MockHost {
1644 prompt: String::new(),
1645 saw: Mutex::new(Vec::new()),
1646 });
1647 let host_for_assert = Arc::clone(&host);
1648 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1649 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1650 let user_data = bridge.user_data();
1651
1652 let mut out = StbString::empty();
1653 let rc = trampoline_runtime_action(
1654 0xFFFF_FFFE,
1655 StbStringRef::from_str("{}"),
1656 &mut out,
1657 user_data,
1658 );
1659
1660 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1661 let payload: serde_json::Value = serde_json::from_str(
1662 &unsafe {
1663 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1664 }
1665 .to_string_lossy(),
1666 )
1667 .expect("structured error JSON");
1668 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1669
1670 assert!(
1671 host_for_assert.saw.lock().unwrap().is_empty(),
1672 "unknown action id must not reach host dispatch"
1673 );
1674 }
1675
1676 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1677 async fn v2_trampoline_accepts_new_action() {
1678 let host = Arc::new(MockHost {
1679 prompt: "v2".to_string(),
1680 saw: Mutex::new(Vec::new()),
1681 });
1682 let host_for_assert = Arc::clone(&host);
1683 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1684 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1685 let user_data = bridge.user_data();
1686
1687 let mut v2_out = StbString::empty();
1688 assert_eq!(
1689 trampoline_runtime_action(
1690 RuntimeActionId::GetCliFlag.into(),
1691 StbStringRef::from_str(r#"{"name":"flag"}"#),
1692 &mut v2_out,
1693 user_data,
1694 ),
1695 0
1696 );
1697 drop(unsafe {
1699 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1700 });
1701
1702 assert_eq!(
1703 *host_for_assert.saw.lock().unwrap(),
1704 vec![RuntimeActionId::GetCliFlag]
1705 );
1706 }
1707
1708 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1713 async fn reload_mailbox_signals_installed_receiver() {
1714 let mailbox = ReloadMailbox::new();
1715 assert!(matches!(mailbox.signal(), Err(())));
1717
1718 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1719 mailbox.install(tx);
1720 let cb = reload_callback_from_mailbox(mailbox.clone());
1721 cb().await;
1722 assert_eq!(
1723 rx.recv().await,
1724 Some(()),
1725 "installed receiver saw the signal"
1726 );
1727 mailbox.clear();
1728 }
1729
1730 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1733 serde_json::json!({
1734 "op": "open",
1735 "requestId": id,
1736 "toolCallId": tool_call,
1737 "ui": {"kind": "selector", "questions": [
1738 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1739 ]},
1740 })
1741 }
1742
1743 #[test]
1746 fn ui_dialog_open_requires_an_attached_ui() {
1747 let mailbox = UiDialogMailbox::new();
1748 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1749 assert!(error.contains("interactive UI"), "got: {error}");
1750 }
1751
1752 #[test]
1753 fn ui_dialog_open_poll_answer_round_trip() {
1754 let mailbox = UiDialogMailbox::new();
1755 mailbox.attach();
1756
1757 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1758 assert_eq!(opened["status"], "pending");
1759 assert_eq!(opened["toolCallId"], "c1");
1760
1761 let request = mailbox.take_pending().expect("request visible to TUI");
1762 assert_eq!(request.request_id, "r1");
1763 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1764 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1766 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1767
1768 mailbox
1769 .respond("r1", serde_json::json!("7890"))
1770 .expect("respond");
1771 let answered = mailbox.poll("r1").unwrap();
1772 assert_eq!(answered["status"], "answered");
1773 assert_eq!(answered["answer"], "7890");
1774 assert!(mailbox.poll("r1").is_err());
1776 }
1777
1778 #[test]
1779 fn ui_dialog_cancel_is_reported_once() {
1780 let mailbox = UiDialogMailbox::new();
1781 mailbox.attach();
1782 mailbox.handle(open_args("r1", "c1")).unwrap();
1783 mailbox.cancel("r1").unwrap();
1784 let cancelled = mailbox.poll("r1").unwrap();
1785 assert_eq!(cancelled["status"], "cancelled");
1786 assert!(mailbox.poll("r1").is_err());
1787 }
1788
1789 #[test]
1790 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1791 let mailbox = UiDialogMailbox::new();
1792 mailbox.attach();
1793 mailbox.handle(open_args("r1", "c1")).unwrap();
1794 mailbox.handle(open_args("r2", "c2")).unwrap();
1795 mailbox.cancel_tool_call("c1");
1796 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1797 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1798 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1799 assert_eq!(
1800 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1801 "cancelled"
1802 );
1803 assert!(mailbox.take_pending().is_none());
1804 }
1805
1806 #[test]
1807 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1808 let mailbox = UiDialogMailbox::new();
1809 mailbox.attach();
1810 mailbox.handle(open_args("r1", "c1")).unwrap();
1811 mailbox.handle(open_args("r2", "c2")).unwrap();
1812
1813 let first = mailbox.take_pending().unwrap().request_id;
1814 let second = mailbox.take_pending().unwrap().request_id;
1815 assert_ne!(first, second);
1816
1817 mailbox
1819 .respond(&second, serde_json::json!("second"))
1820 .unwrap();
1821 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1822 let second_answer = mailbox.poll(&second).unwrap();
1823 assert_eq!(second_answer["answer"], "second");
1824
1825 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1826 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1827 }
1828
1829 #[test]
1830 fn ui_dialog_detach_cancels_outstanding() {
1831 let mailbox = UiDialogMailbox::new();
1832 mailbox.attach();
1833 mailbox.handle(open_args("r1", "c1")).unwrap();
1834 mailbox.detach();
1835 assert!(!mailbox.is_attached());
1836 assert_eq!(mailbox.pending_len(), 0);
1837 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1839 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1840 }
1841
1842 #[test]
1843 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1844 let mailbox = UiDialogMailbox::new();
1845 mailbox.attach();
1846 mailbox.handle(open_args("r1", "c1")).unwrap();
1847 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1848 assert_eq!(again["status"], "pending");
1849 assert_eq!(mailbox.pending_len(), 1);
1850 mailbox.take_pending().unwrap();
1851 assert!(mailbox.take_pending().is_none());
1852 }
1853
1854 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1858 async fn v2_dispatch_routes_ui_dialog_action_17() {
1859 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1860 prompt: String::new(),
1861 saw: Mutex::new(Vec::new()),
1862 });
1863 let bridge = ActionBridge::with_ui_dialog(
1864 tokio::runtime::Handle::current(),
1865 host,
1866 UiDialogMailbox::new(),
1867 );
1868 let user_data = bridge.user_data();
1869
1870 let mut out = StbString::empty();
1871 let rc = trampoline_runtime_action(
1872 17,
1873 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1874 &mut out,
1875 user_data,
1876 );
1877 assert_eq!(rc, 1);
1878 let payload: serde_json::Value = serde_json::from_str(
1879 &unsafe {
1880 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1881 }
1882 .to_string_lossy(),
1883 )
1884 .expect("structured error JSON");
1885 assert!(
1886 payload["error"]
1887 .as_str()
1888 .is_some_and(|error| error.contains("interactive UI")),
1889 "unexpected payload: {payload}"
1890 );
1891
1892 let mut out = StbString::empty();
1895 assert_eq!(
1896 trampoline_runtime_action(25, StbStringRef::from_str("{}"), &mut out, user_data),
1897 2
1898 );
1899 drop(unsafe {
1901 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1902 });
1903 }
1904
1905 #[tokio::test(flavor = "current_thread")]
1906 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1907 let host = Arc::new(MockHost {
1908 prompt: String::new(),
1909 saw: Mutex::new(Vec::new()),
1910 });
1911 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1912 let a = root.for_plugin("a");
1913 let b = root.for_plugin("b");
1914 let mailbox = root.extension_status_mailbox();
1915 for plugin in [&a, &b] {
1916 let mut out = StbString::empty();
1917 assert_eq!(
1918 trampoline_runtime_action(
1919 22,
1920 StbStringRef::from_str(
1921 r#"{"op":"register","id":"same","slot":"footer.after"}"#
1922 ),
1923 &mut out,
1924 plugin.user_data()
1925 ),
1926 0
1927 );
1928 drop(unsafe {
1930 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1931 });
1932 }
1933 assert_eq!(mailbox.panels().len(), 2);
1934 drop(a);
1935 assert_eq!(mailbox.panels().len(), 1);
1936 root.invalidate();
1937 assert!(mailbox.panels().is_empty());
1938 }
1939
1940 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1943 async fn v2_dispatch_routes_set_status_action_18() {
1944 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1945 prompt: String::new(),
1946 saw: Mutex::new(Vec::new()),
1947 });
1948 let status = crate::status::ExtensionStatusMailbox::new();
1949 let bridge = ActionBridge::with_reload_and_ui(
1950 tokio::runtime::Handle::current(),
1951 host,
1952 Arc::new(|| Box::pin(async {})),
1953 UiDialogMailbox::new(),
1954 status.clone(),
1955 crate::editor_text::EditorTextMailbox::new(),
1956 );
1957 let user_data = bridge.user_data();
1958
1959 let mut out = StbString::empty();
1960 let rc = trampoline_runtime_action(
1961 18,
1962 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1963 &mut out,
1964 user_data,
1965 );
1966 assert_eq!(rc, 0);
1967 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1968 drop(unsafe {
1970 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1971 });
1972
1973 let mut out = StbString::empty();
1975 assert_eq!(
1976 trampoline_runtime_action(
1977 18,
1978 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1979 &mut out,
1980 user_data
1981 ),
1982 0
1983 );
1984 assert_eq!(status.text(), "");
1985 drop(unsafe {
1987 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1988 });
1989
1990 let mut out = StbString::empty();
1992 assert_eq!(
1993 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1994 1
1995 );
1996 drop(unsafe {
1998 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1999 });
2000 }
2001}
2002
2003impl Drop for ActionBridge {
2004 fn drop(&mut self) {
2005 bridges()
2006 .lock()
2007 .unwrap_or_else(|e| e.into_inner())
2008 .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2009 if self.invocation.is_some() {
2010 return;
2011 }
2012 if let Some((owner, _)) = &self.plugin_identity {
2013 self.ext_status.remove_owner(*owner);
2014 self.host.cancel_child_agents(*owner);
2015 }
2016 }
2017}
2018
2019#[cfg(test)]
2020mod runtime_tests;