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
362pub type SideQuestionSink =
364 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>;
365
366#[async_trait::async_trait]
381pub trait RuntimeActionHost: Send + Sync {
382 fn cancel_side_questions(&self, _owner: u64) {}
384 fn side_question_revision(&self) -> u64 {
386 0
387 }
388 fn side_question_views(&self) -> Vec<serde_json::Value> {
389 Vec::new()
390 }
391 async fn side_question_ui(
392 &self,
393 _owner: u64,
394 _thread_id: String,
395 _args: serde_json::Value,
396 ) -> Result<serde_json::Value, String> {
397 Err("SideQuestion UI is not supported by this host".into())
398 }
399 async fn side_question(
401 &self,
402 _owner: u64,
403 _args: serde_json::Value,
404 _sink: SideQuestionSink,
405 ) -> Result<serde_json::Value, String> {
406 Err("SideQuestion is not supported by this host; upgrade rpi".into())
407 }
408
409 fn replace_model_catalog(&self, _models: Vec<rpi_ai::Model>) {}
411 fn cancel_child_agents(&self, _owner: u64) {}
413 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
418 async fn send_user_message(&self, args: serde_json::Value)
420 -> Result<serde_json::Value, String>;
421 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
426 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
428 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
430 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
432 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
434 async fn get_thinking_level(
436 &self,
437 args: serde_json::Value,
438 ) -> Result<serde_json::Value, String>;
439 async fn set_thinking_level(
441 &self,
442 args: serde_json::Value,
443 ) -> Result<serde_json::Value, String>;
444 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
446 async fn get_system_prompt(&self, args: serde_json::Value)
448 -> Result<serde_json::Value, String>;
449 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
451 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
453 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
455 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
457 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
461
462 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
465 Err("CLI flag lookup is not configured".to_string())
466 }
467
468 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
472 Err("ask_user requires an interactive UI".to_string())
473 }
474
475 async fn get_execution_context(
477 &self,
478 _args: serde_json::Value,
479 ) -> Result<serde_json::Value, String> {
480 Err("execution context is not supported by this host".into())
481 }
482
483 async fn child_agent(
485 &self,
486 _owner: u64,
487 _args: serde_json::Value,
488 ) -> Result<serde_json::Value, String> {
489 Err("ChildAgent is not supported by this host".into())
490 }
491
492 async fn get_session_branch(
494 &self,
495 _args: serde_json::Value,
496 ) -> Result<serde_json::Value, String> {
497 Err("session branch lookup is not configured".to_string())
498 }
499}
500
501pub struct ActionBridge {
528 invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
530 execution_context: Option<serde_json::Value>,
531 stdio: crate::StdioLifecycle,
532 plugin_identity: Option<(u64, String)>,
533 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
534 pub(crate) runtime: Handle,
536 pub(crate) host: Arc<dyn RuntimeActionHost>,
538 pub(crate) reload:
540 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
541 ui_dialog: UiDialogMailbox,
544 ext_status: crate::status::ExtensionStatusMailbox,
548 editor_text: crate::editor_text::EditorTextMailbox,
552 active: Arc<AtomicBool>,
555}
556
557impl ActionBridge {
558 pub fn user_data(self: &Arc<Self>) -> *mut c_void {
560 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
561 let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
562 if let Some((&id, _)) = registry
563 .iter()
564 .find(|(_, weak)| weak.as_ptr() == Arc::as_ptr(self))
565 {
566 return id as *mut c_void;
567 }
568 let mut id = NEXT.load(Ordering::Relaxed);
569 loop {
570 let next = id.checked_add(1).expect("runtime handle space exhausted");
571 match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
572 Ok(_) => break,
573 Err(current) => id = current,
574 }
575 }
576 registry.insert(id, Arc::downgrade(self));
577 id as *mut c_void
578 }
579 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
580 bridges()
581 .lock()
582 .unwrap_or_else(|e| e.into_inner())
583 .get(&id)
584 .and_then(Weak::upgrade)
585 }
586 pub(crate) fn for_invocation(
587 &self,
588 child: bool,
589 signal: tokio_util::sync::CancellationToken,
590 ) -> Arc<Self> {
591 Arc::new(Self {
592 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
593 execution_context: rpi_agent::tool_execution_scope::execution_context(),
594 stdio: self.stdio.clone(),
595 plugin_identity: self.plugin_identity.clone(),
596 component_owners: self.component_owners.clone(),
597 runtime: self.runtime.clone(),
598 host: self.host.clone(),
599 reload: self.reload.clone(),
600 ui_dialog: self.ui_dialog.clone(),
601 ext_status: self.ext_status.clone(),
602 editor_text: self.editor_text.clone(),
603 active: self.active.clone(),
604 })
605 }
606 pub(crate) fn invocation_key(&self) -> Option<&str> {
607 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
608 }
609 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
610 InvocationGuard {
611 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
612 _thread_bound: std::marker::PhantomData,
613 }
614 }
615 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
618 Arc::new(Self {
619 invocation: None,
620 execution_context: None,
621 stdio: crate::StdioLifecycle::default(),
622 plugin_identity: None,
623 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
624 runtime,
625 host,
626 reload: None,
627 ui_dialog: UiDialogMailbox::new(),
628 ext_status: crate::status::ExtensionStatusMailbox::new(),
629 editor_text: crate::editor_text::EditorTextMailbox::new(),
630 active: Arc::new(AtomicBool::new(true)),
631 })
632 }
633
634 pub fn with_reload(
637 runtime: Handle,
638 host: Arc<dyn RuntimeActionHost>,
639 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
640 ) -> Arc<Self> {
641 Self::with_reload_and_ui(
642 runtime,
643 host,
644 reload,
645 UiDialogMailbox::new(),
646 crate::status::ExtensionStatusMailbox::new(),
647 crate::editor_text::EditorTextMailbox::new(),
648 )
649 }
650
651 pub fn with_ui_dialog(
653 runtime: Handle,
654 host: Arc<dyn RuntimeActionHost>,
655 ui_dialog: UiDialogMailbox,
656 ) -> Arc<Self> {
657 Arc::new(Self {
658 invocation: None,
659 execution_context: None,
660 stdio: crate::StdioLifecycle::default(),
661 plugin_identity: None,
662 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
663 runtime,
664 host,
665 reload: None,
666 ui_dialog,
667 ext_status: crate::status::ExtensionStatusMailbox::new(),
668 editor_text: crate::editor_text::EditorTextMailbox::new(),
669 active: Arc::new(AtomicBool::new(true)),
670 })
671 }
672
673 pub fn with_reload_and_ui(
675 runtime: Handle,
676 host: Arc<dyn RuntimeActionHost>,
677 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
678 ui_dialog: UiDialogMailbox,
679 ext_status: crate::status::ExtensionStatusMailbox,
680 editor_text: crate::editor_text::EditorTextMailbox,
681 ) -> Arc<Self> {
682 Arc::new(Self {
683 invocation: None,
684 execution_context: None,
685 stdio: crate::StdioLifecycle::default(),
686 plugin_identity: None,
687 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
688 runtime,
689 host,
690 reload: Some(reload),
691 ui_dialog,
692 ext_status,
693 editor_text,
694 active: Arc::new(AtomicBool::new(true)),
695 })
696 }
697
698 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
699 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
700 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
701 self.component_owners
702 .lock()
703 .unwrap_or_else(|e| e.into_inner())
704 .push(owner);
705 Arc::new(Self {
706 invocation: None,
707 execution_context: None,
708 component_owners: self.component_owners.clone(),
709 stdio: self.stdio.clone(),
710 plugin_identity: Some((owner, name.to_owned())),
711 runtime: self.runtime.clone(),
712 host: self.host.clone(),
713 reload: self.reload.clone(),
714 ui_dialog: self.ui_dialog.clone(),
715 ext_status: self.ext_status.clone(),
716 editor_text: self.editor_text.clone(),
717 active: self.active.clone(),
718 })
719 }
720
721 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
722 self.stdio.clone()
723 }
724
725 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
726 let (id, name) = self
727 .plugin_identity
728 .as_ref()
729 .ok_or("missing plugin identity")?;
730 self.stdio.claim(*id, name)
731 }
732
733 pub(crate) fn release_stdio_claim(&self) {
734 if let Some((id, _)) = &self.plugin_identity {
735 self.stdio.release_failed_registration(*id);
736 self.ext_status.remove_owner(*id);
737 }
738 }
739
740 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
742 self.ui_dialog.clone()
743 }
744
745 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
747 self.ext_status.clone()
748 }
749
750 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
752 self.editor_text.clone()
753 }
754
755 pub fn invalidate(&self) {
760 let owners = {
761 let owners = self
762 .component_owners
763 .lock()
764 .unwrap_or_else(|e| e.into_inner());
765 self.active.store(false, Ordering::SeqCst);
766 for owner in owners.iter() {
767 self.ext_status.remove_owner(*owner);
768 }
769 owners.clone()
770 };
771 for owner in owners {
774 self.host.cancel_child_agents(owner);
775 self.host.cancel_side_questions(owner);
776 }
777 }
778
779 pub fn is_active(&self) -> bool {
781 self.active.load(Ordering::SeqCst)
782 }
783
784 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
791 Arc::clone(&self.host)
792 }
793}
794
795#[derive(Clone)]
808pub struct ReloadMailbox {
809 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
810}
811
812impl Default for ReloadMailbox {
813 fn default() -> Self {
814 Self {
815 tx: Arc::new(std::sync::Mutex::new(None)),
816 }
817 }
818}
819
820impl ReloadMailbox {
821 pub fn new() -> Self {
823 Self::default()
824 }
825
826 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
830 *self.tx.lock().unwrap() = Some(tx);
831 }
832
833 pub fn signal(&self) -> Result<(), ()> {
838 let g = self.tx.lock().unwrap();
839 match &*g {
840 Some(tx) => {
841 let _ = tx.send(());
842 Ok(())
843 }
844 None => Err(()),
845 }
846 }
847
848 pub fn clear(&self) {
850 *self.tx.lock().unwrap() = None;
851 }
852}
853
854pub fn reload_callback_from_mailbox(
861 mailbox: ReloadMailbox,
862) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
863 Arc::new(move || {
864 let m = mailbox.clone();
865 Box::pin(async move {
866 let _ = m.signal();
867 })
868 })
869}
870
871async fn dispatch(
882 host: &Arc<dyn RuntimeActionHost>,
883 ui_dialog: &UiDialogMailbox,
884 ext_status: &crate::status::ExtensionStatusMailbox,
885 editor_text: &crate::editor_text::EditorTextMailbox,
886 action: RuntimeActionId,
887 args: serde_json::Value,
888) -> Result<serde_json::Value, String> {
889 match action {
890 RuntimeActionId::SendMessage => host.send_message(args).await,
891 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
892 RuntimeActionId::AppendEntry => host.append_entry(args).await,
893 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
894 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
895 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
896 RuntimeActionId::SetModel => host.set_model(args).await,
897 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
898 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
899 RuntimeActionId::Compact => host.compact(args).await,
900 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
901 RuntimeActionId::NewSession => host.new_session(args).await,
902 RuntimeActionId::Fork => host.fork(args).await,
903 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
904 RuntimeActionId::SwitchSession => host.switch_session(args).await,
905 RuntimeActionId::Reload => host.reload(args).await,
906 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
907 RuntimeActionId::UiDialog => ui_dialog.handle(args),
908 RuntimeActionId::SetStatus => ext_status.handle(args),
911 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
912 RuntimeActionId::SetEditorText => editor_text.handle(args),
915 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
916 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
917 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
918 RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
919 RuntimeActionId::StdioLifecycle => {
920 Err("stdio lifecycle requires a plugin context".to_owned())
921 }
922 }
923}
924
925pub extern "C" fn trampoline_runtime_action(
949 action_id: u32,
950 args_json: StbStringRef,
951 out: *mut StbString,
952 user_data: *mut c_void,
953) -> i32 {
954 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
955 run_action(
956 action_id,
957 u32::from(RuntimeActionId::SideQuestion),
959 args_json,
960 out,
961 user_data,
962 )
963 }));
964 match outcome {
965 Ok(rc) => rc,
966 Err(_) => {
967 tracing::error!("runtime_action trampoline panicked — reporting host error");
971 if !out.is_null() {
972 unsafe {
973 *out = StbString::from_string(
974 r#"{"error":"runtime_action panicked"}"#.to_string(),
975 );
976 }
977 }
978 1
979 }
980 }
981}
982
983fn run_action(
985 action_id: u32,
986 max_action_id: u32,
987 args_json: StbStringRef,
988 out: *mut StbString,
989 user_data: *mut c_void,
990) -> i32 {
991 let action = match RuntimeActionId::try_from(action_id) {
995 Ok(action) if action_id <= max_action_id => action,
996 Ok(_) | Err(_) => {
997 if !out.is_null() {
998 let json = serde_json::json!({
999 "error": format!("unknown runtime action id {action_id}")
1000 })
1001 .to_string();
1002 unsafe {
1006 *out = StbString::from_string(json);
1007 }
1008 }
1009 return 2;
1010 }
1011 };
1012
1013 if user_data.is_null() {
1014 return -1;
1015 }
1016 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1019 let invocation_thread = invocation.is_some();
1020 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1021 let Some(bridge) = bridge else {
1022 if !out.is_null() {
1023 unsafe {
1025 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1026 }
1027 }
1028 return 1;
1029 };
1030
1031 if !bridge.is_active() {
1039 if out.is_null() {
1040 return 1;
1041 }
1042 let json = serde_json::json!({
1043 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1044 })
1045 .to_string();
1046 unsafe {
1047 *out = StbString::from_string(json);
1048 }
1049 return 1;
1050 }
1051
1052 let args_str = unsafe { args_json.as_str() };
1055 let mut args: serde_json::Value = if args_str.is_empty() {
1056 serde_json::Value::Object(serde_json::Map::new())
1057 } else {
1058 serde_json::from_str(args_str)
1059 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1060 };
1061
1062 let runtime_key = args
1063 .as_object_mut()
1064 .and_then(|args| args.remove("__rpiRuntimeKey"));
1065
1066 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1071 let host = Arc::clone(&bridge.host);
1075 let ui_dialog = bridge.ui_dialog.clone();
1076 let ext_status = bridge.ext_status.clone();
1077 let editor_text = bridge.editor_text.clone();
1078 let reload_cb = bridge.reload.clone();
1079 let execution_context = bridge.execution_context.clone();
1080 if action == RuntimeActionId::StdioLifecycle {
1081 let result = bridge.stdio.handle(
1082 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1083 args,
1084 );
1085 let _ = tx.send(result);
1086 } else if action == RuntimeActionId::UiComponent {
1087 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1088 let _owners = bridge
1089 .component_owners
1090 .lock()
1091 .unwrap_or_else(|e| e.into_inner());
1092 let result = if bridge.is_active() {
1093 ext_status.component(owner, args)
1094 } else {
1095 Err("stale plugin bridge".into())
1096 };
1097 let _ = tx.send(result);
1098 } else if action == RuntimeActionId::SideQuestion {
1099 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1100 if owner == 0 || bridge.invocation.is_some() {
1101 let _ = tx.send(Err(
1102 "SideQuestion requires a plugin command, not a tool invocation".into(),
1103 ));
1104 } else {
1105 let weak = Arc::downgrade(&bridge);
1108 let sink: SideQuestionSink = Arc::new(move |args| {
1109 let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1110 let _owners = bridge
1111 .component_owners
1112 .lock()
1113 .unwrap_or_else(|e| e.into_inner());
1114 if !bridge.is_active() {
1115 return Err("stale side question bridge".into());
1116 }
1117 if args["op"] == "check" {
1118 return Ok(serde_json::json!({"ok":true}));
1119 }
1120 bridge.ext_status.component(owner, args)
1121 });
1122 bridge.runtime.spawn(async move {
1123 let _ = tx.send(host.side_question(owner, args, sink).await);
1124 });
1125 }
1126 } else if action == RuntimeActionId::ChildAgent {
1127 let cleanup = matches!(
1132 args.get("op").and_then(serde_json::Value::as_str),
1133 Some("poll" | "cancel" | "dispose")
1134 );
1135 let allowed = bridge
1136 .invocation
1137 .as_ref()
1138 .is_some_and(|(child, key, signal)| {
1139 !child
1140 && (cleanup || !signal.is_cancelled())
1141 && (invocation_thread
1142 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1143 == Some(key.as_str()))
1144 });
1145 if !allowed {
1146 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1147 } else {
1148 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1149 let signal = bridge
1150 .invocation
1151 .as_ref()
1152 .expect("authorized invocation")
1153 .2
1154 .clone();
1155 bridge.runtime.spawn(async move {
1156 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1157 signal,
1158 host.child_agent(owner, args),
1159 )
1160 .await;
1161 let _ = tx.send(result);
1162 });
1163 }
1164 } else if action == RuntimeActionId::SetStatus {
1165 std::thread::spawn(move || {
1170 let _ = tx.send(ext_status.handle(args));
1171 });
1172 } else if action == RuntimeActionId::SetEditorText {
1173 std::thread::spawn(move || {
1176 let _ = tx.send(editor_text.handle(args));
1177 });
1178 } else if action == RuntimeActionId::UiDialog {
1179 std::thread::spawn(move || {
1185 let _ = tx.send(ui_dialog.handle(args));
1186 });
1187 } else {
1188 bridge.runtime.spawn(async move {
1189 let mut r = if action == RuntimeActionId::Reload {
1190 if let Some(cb) = reload_cb {
1191 cb().await;
1192 Ok(serde_json::Value::Null)
1193 } else {
1194 host.reload(args).await
1195 }
1196 } else {
1197 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1198 };
1199 if action == RuntimeActionId::GetExecutionContext {
1200 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1201 (&mut r, execution_context)
1202 {
1203 if let Some(object) = context.as_object_mut() {
1204 object.extend(fields);
1205 }
1206 }
1207 }
1208 let _ = tx.send(r);
1211 });
1212 }
1213
1214 let received = if Handle::try_current()
1220 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1221 {
1222 tokio::task::block_in_place(|| rx.recv())
1223 } else {
1224 rx.recv()
1225 };
1226 let result = match received {
1227 Ok(r) => r,
1228 Err(_) => {
1229 return -2;
1234 }
1235 };
1236
1237 if out.is_null() {
1240 return match result {
1242 Ok(_) => 0,
1243 Err(_) => 1,
1244 };
1245 }
1246
1247 let (rc, payload) = match result {
1248 Ok(value) => {
1249 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1250 (0, json)
1251 }
1252 Err(msg) => {
1253 let json = serde_json::json!({ "error": msg }).to_string();
1254 (1, json)
1255 }
1256 };
1257 unsafe {
1263 *out = StbString::from_string(payload);
1264 }
1265 rc
1266}
1267
1268#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use std::sync::Mutex;
1280
1281 #[tokio::test(flavor = "current_thread")]
1282 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1283 let master = ActionBridge::new(
1284 Handle::current(),
1285 Arc::new(MockHost {
1286 prompt: String::new(),
1287 saw: Mutex::new(Vec::new()),
1288 }),
1289 );
1290 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1291 let owner = crate::HostApi::with_action_bridge(
1292 "owner",
1293 crate::ExtensionRegistry::new(),
1294 diag.clone(),
1295 master.clone(),
1296 );
1297 let other = crate::HostApi::with_action_bridge(
1298 "other",
1299 crate::ExtensionRegistry::new(),
1300 diag,
1301 master.clone(),
1302 );
1303 let vtable = owner.build_vtable();
1304 let claim = r#"{"ownsStdio":true}"#;
1305 {
1306 let _registration = crate::enter_current_api(&owner);
1307 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1308 }
1309 let other_vtable = other.build_vtable();
1310 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1311 let mut out = StbString::empty();
1312 assert_eq!(
1314 (other_vtable.runtime_action)(
1315 RuntimeActionId::StdioLifecycle as u32,
1316 finish,
1317 &mut out,
1318 other_vtable.user_data
1319 ),
1320 1
1321 );
1322 unsafe { (other_vtable.free_string)(out) };
1324 assert_eq!(
1325 (vtable.runtime_action)(
1326 RuntimeActionId::StdioLifecycle as u32,
1327 finish,
1328 &mut out,
1329 vtable.user_data
1330 ),
1331 0
1332 );
1333 unsafe { (vtable.free_string)(out) };
1335 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1336 {
1337 let _registration = crate::enter_current_api(&other);
1338 assert_ne!(
1339 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1340 0
1341 );
1342 }
1343 assert!(master
1344 .stdio_lifecycle()
1345 .validate()
1346 .unwrap_err()
1347 .contains("owner"));
1348 }
1349
1350 struct MockHost {
1355 prompt: String,
1356 saw: Mutex<Vec<RuntimeActionId>>,
1357 }
1358
1359 #[test]
1360 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1361 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1362 if std::env::var_os(CHILD).is_some() {
1363 let runtime = tokio::runtime::Builder::new_multi_thread()
1364 .worker_threads(1)
1365 .enable_all()
1366 .build()
1367 .unwrap();
1368 runtime.block_on(async {
1369 tokio::spawn(async {
1372 let host = Arc::new(MockHost {
1373 prompt: "worker callback".into(),
1374 saw: Mutex::new(Vec::new()),
1375 });
1376 let bridge = ActionBridge::new(Handle::current(), host);
1377 let mut out = StbString::empty();
1378 assert_eq!(
1379 trampoline_runtime_action(
1380 RuntimeActionId::GetSystemPrompt.into(),
1381 StbStringRef::from_str("{}"),
1382 &mut out,
1383 bridge.user_data(),
1384 ),
1385 0
1386 );
1387 let text = unsafe {
1389 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1390 out,
1391 Some(crate::host_free_string),
1392 )
1393 }
1394 .to_string_lossy();
1395 assert_eq!(
1396 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1397 "worker callback"
1398 );
1399 })
1400 .await
1401 .unwrap();
1402 });
1403 return;
1404 }
1405 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1408 .args([
1409 "--exact",
1410 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1411 "--nocapture",
1412 ])
1413 .env(CHILD, "1")
1414 .stdout(std::process::Stdio::piped())
1415 .stderr(std::process::Stdio::piped())
1416 .spawn()
1417 .unwrap();
1418 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1419 while child.try_wait().unwrap().is_none() {
1420 if std::time::Instant::now() >= deadline {
1421 let _ = child.kill();
1422 let _ = child.wait();
1423 panic!("runtime action blocked the worker needed by its own dispatch");
1424 }
1425 std::thread::sleep(std::time::Duration::from_millis(10));
1426 }
1427 let output = child.wait_with_output().unwrap();
1428 assert!(
1429 output.status.success(),
1430 "{}\n{}",
1431 String::from_utf8_lossy(&output.stdout),
1432 String::from_utf8_lossy(&output.stderr)
1433 );
1434 }
1435
1436 #[async_trait::async_trait]
1437 impl RuntimeActionHost for MockHost {
1438 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1439 unreachable!("not under test")
1440 }
1441 async fn send_user_message(
1442 &self,
1443 _: serde_json::Value,
1444 ) -> Result<serde_json::Value, String> {
1445 unreachable!("not under test")
1446 }
1447 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1448 unreachable!("not under test")
1449 }
1450 async fn set_session_name(
1451 &self,
1452 _: serde_json::Value,
1453 ) -> Result<serde_json::Value, String> {
1454 unreachable!("not under test")
1455 }
1456 async fn get_active_tools(
1457 &self,
1458 _: serde_json::Value,
1459 ) -> Result<serde_json::Value, String> {
1460 unreachable!("not under test")
1461 }
1462 async fn set_active_tools(
1463 &self,
1464 _: serde_json::Value,
1465 ) -> Result<serde_json::Value, String> {
1466 unreachable!("not under test")
1467 }
1468 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1469 unreachable!("not under test")
1470 }
1471 async fn get_thinking_level(
1472 &self,
1473 _: serde_json::Value,
1474 ) -> Result<serde_json::Value, String> {
1475 unreachable!("not under test")
1476 }
1477 async fn set_thinking_level(
1478 &self,
1479 _: serde_json::Value,
1480 ) -> Result<serde_json::Value, String> {
1481 unreachable!("not under test")
1482 }
1483 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1484 unreachable!("not under test")
1485 }
1486 async fn get_system_prompt(
1487 &self,
1488 _: serde_json::Value,
1489 ) -> Result<serde_json::Value, String> {
1490 self.saw
1491 .lock()
1492 .unwrap()
1493 .push(RuntimeActionId::GetSystemPrompt);
1494 Ok(serde_json::json!({ "prompt": self.prompt }))
1495 }
1496 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1497 unreachable!("not under test")
1498 }
1499 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1500 unreachable!("not under test")
1501 }
1502 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1503 unreachable!("not under test")
1504 }
1505 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1506 unreachable!("not under test")
1507 }
1508 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1509 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1510 Ok(serde_json::Value::Null)
1511 }
1512 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1513 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1514 Ok(serde_json::json!({ "value": true }))
1515 }
1516 }
1517
1518 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1522 async fn trampoline_round_trips_get_system_prompt() {
1523 let host = Arc::new(MockHost {
1524 prompt: "hello from host".to_string(),
1525 saw: Mutex::new(Vec::new()),
1526 });
1527 let host_for_assert = Arc::clone(&host);
1528 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1529 let runtime = tokio::runtime::Handle::current();
1530 let bridge = ActionBridge::new(runtime, host_dyn);
1531 let user_data = bridge.user_data();
1532
1533 let args_str = "{}";
1535 let args_ref = StbStringRef::from_str(args_str);
1536
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 user_data,
1543 );
1544 assert_eq!(rc, 0, "success return code");
1545
1546 let json_text = unsafe {
1549 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1550 }
1551 .to_string_lossy();
1552 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1553 assert_eq!(parsed["prompt"], "hello from host");
1554
1555 let saw = host_for_assert.saw.lock().unwrap().clone();
1558 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1559 }
1560
1561 #[tokio::test]
1567 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1568 let host = Arc::new(MockHost {
1569 prompt: String::new(),
1570 saw: Mutex::new(Vec::new()),
1571 });
1572 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1573 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1574 let mailbox = bridge.editor_text_mailbox();
1575 let user_data = bridge.user_data();
1576
1577 let args_ref =
1578 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1579 let mut out = StbString::empty();
1580 let rc = trampoline_runtime_action(
1581 RuntimeActionId::SetEditorText.into(),
1582 args_ref,
1583 &mut out as *mut StbString,
1584 user_data,
1585 );
1586 assert_eq!(rc, 0, "success return code");
1587
1588 let parsed: serde_json::Value = serde_json::from_str(
1590 &unsafe {
1591 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1592 }
1593 .to_string_lossy(),
1594 )
1595 .expect("valid json");
1596 assert_eq!(parsed["ok"], serde_json::json!(true));
1597 assert_eq!(parsed["chars"], serde_json::json!(10));
1598
1599 let edit = mailbox.take_pending().expect("queued edit");
1601 assert_eq!(edit.text, "from voice");
1602 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1603 assert_eq!(edit.auto_send_ms, Some(2000));
1604 assert!(mailbox.take_pending().is_none());
1605 }
1606
1607 #[tokio::test]
1608 async fn trampoline_null_user_data_returns_minus_one() {
1609 let args_ref = StbStringRef::from_str("{}");
1610 let mut out = StbString::empty();
1611 let rc = trampoline_runtime_action(
1612 RuntimeActionId::GetSystemPrompt.into(),
1613 args_ref,
1614 &mut out as *mut StbString,
1615 std::ptr::null_mut(),
1616 );
1617 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1618 }
1619
1620 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1621 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1622 use std::sync::atomic::{AtomicUsize, Ordering};
1626 let reload_calls = Arc::new(AtomicUsize::new(0));
1627 let reload_calls_for_cb = Arc::clone(&reload_calls);
1628 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1629 Arc::new(move || {
1630 let c = Arc::clone(&reload_calls_for_cb);
1631 Box::pin(async move {
1632 c.fetch_add(1, Ordering::SeqCst);
1633 })
1634 });
1635 let host = Arc::new(MockHost {
1636 prompt: String::new(),
1637 saw: Mutex::new(Vec::new()),
1638 });
1639 let host_for_assert = Arc::clone(&host);
1640 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1641 let runtime = tokio::runtime::Handle::current();
1642 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1643 let user_data = bridge.user_data();
1644
1645 let args_ref = StbStringRef::from_str("{}");
1646 let mut out = StbString::empty();
1647 let rc = trampoline_runtime_action(
1648 RuntimeActionId::Reload.into(),
1649 args_ref,
1650 &mut out as *mut StbString,
1651 user_data,
1652 );
1653 assert_eq!(rc, 0);
1654 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1656 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1657 drop(unsafe {
1659 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1660 });
1661 }
1662
1663 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1668 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1669 let host = Arc::new(MockHost {
1670 prompt: String::new(),
1671 saw: Mutex::new(Vec::new()),
1672 });
1673 let host_for_assert = Arc::clone(&host);
1674 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1675 let runtime = tokio::runtime::Handle::current();
1676 let bridge = ActionBridge::new(runtime, host_dyn);
1677 let user_data = bridge.user_data();
1678
1679 bridge.invalidate();
1681 assert!(!bridge.is_active());
1682
1683 let args_ref = StbStringRef::from_str("{}");
1684 let mut out = StbString::empty();
1685 let rc = trampoline_runtime_action(
1686 RuntimeActionId::GetSystemPrompt.into(),
1687 args_ref,
1688 &mut out as *mut StbString,
1689 user_data,
1690 );
1691 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1693 let json_text = unsafe {
1695 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1696 }
1697 .to_string_lossy();
1698 assert!(
1699 json_text.contains("stale"),
1700 "stale-bridge error payload: {json_text}"
1701 );
1702
1703 assert!(
1704 host_for_assert.saw.lock().unwrap().is_empty(),
1705 "host dispatch must NOT run on a stale bridge"
1706 );
1707 }
1708
1709 #[tokio::test]
1710 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1711 let host = Arc::new(MockHost {
1712 prompt: String::new(),
1713 saw: Mutex::new(Vec::new()),
1714 });
1715 let host_for_assert = Arc::clone(&host);
1716 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1717 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1718 let user_data = bridge.user_data();
1719
1720 let mut out = StbString::empty();
1721 let rc = trampoline_runtime_action(
1722 0xFFFF_FFFE,
1723 StbStringRef::from_str("{}"),
1724 &mut out,
1725 user_data,
1726 );
1727
1728 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1729 let payload: serde_json::Value = serde_json::from_str(
1730 &unsafe {
1731 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1732 }
1733 .to_string_lossy(),
1734 )
1735 .expect("structured error JSON");
1736 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1737
1738 assert!(
1739 host_for_assert.saw.lock().unwrap().is_empty(),
1740 "unknown action id must not reach host dispatch"
1741 );
1742 }
1743
1744 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1745 async fn v2_trampoline_accepts_new_action() {
1746 let host = Arc::new(MockHost {
1747 prompt: "v2".to_string(),
1748 saw: Mutex::new(Vec::new()),
1749 });
1750 let host_for_assert = Arc::clone(&host);
1751 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1752 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1753 let user_data = bridge.user_data();
1754
1755 let mut v2_out = StbString::empty();
1756 assert_eq!(
1757 trampoline_runtime_action(
1758 RuntimeActionId::GetCliFlag.into(),
1759 StbStringRef::from_str(r#"{"name":"flag"}"#),
1760 &mut v2_out,
1761 user_data,
1762 ),
1763 0
1764 );
1765 drop(unsafe {
1767 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1768 });
1769
1770 assert_eq!(
1771 *host_for_assert.saw.lock().unwrap(),
1772 vec![RuntimeActionId::GetCliFlag]
1773 );
1774 }
1775
1776 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1781 async fn reload_mailbox_signals_installed_receiver() {
1782 let mailbox = ReloadMailbox::new();
1783 assert!(matches!(mailbox.signal(), Err(())));
1785
1786 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1787 mailbox.install(tx);
1788 let cb = reload_callback_from_mailbox(mailbox.clone());
1789 cb().await;
1790 assert_eq!(
1791 rx.recv().await,
1792 Some(()),
1793 "installed receiver saw the signal"
1794 );
1795 mailbox.clear();
1796 }
1797
1798 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1801 serde_json::json!({
1802 "op": "open",
1803 "requestId": id,
1804 "toolCallId": tool_call,
1805 "ui": {"kind": "selector", "questions": [
1806 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1807 ]},
1808 })
1809 }
1810
1811 #[test]
1814 fn ui_dialog_open_requires_an_attached_ui() {
1815 let mailbox = UiDialogMailbox::new();
1816 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1817 assert!(error.contains("interactive UI"), "got: {error}");
1818 }
1819
1820 #[test]
1821 fn ui_dialog_open_poll_answer_round_trip() {
1822 let mailbox = UiDialogMailbox::new();
1823 mailbox.attach();
1824
1825 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1826 assert_eq!(opened["status"], "pending");
1827 assert_eq!(opened["toolCallId"], "c1");
1828
1829 let request = mailbox.take_pending().expect("request visible to TUI");
1830 assert_eq!(request.request_id, "r1");
1831 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1832 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1834 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1835
1836 mailbox
1837 .respond("r1", serde_json::json!("7890"))
1838 .expect("respond");
1839 let answered = mailbox.poll("r1").unwrap();
1840 assert_eq!(answered["status"], "answered");
1841 assert_eq!(answered["answer"], "7890");
1842 assert!(mailbox.poll("r1").is_err());
1844 }
1845
1846 #[test]
1847 fn ui_dialog_cancel_is_reported_once() {
1848 let mailbox = UiDialogMailbox::new();
1849 mailbox.attach();
1850 mailbox.handle(open_args("r1", "c1")).unwrap();
1851 mailbox.cancel("r1").unwrap();
1852 let cancelled = mailbox.poll("r1").unwrap();
1853 assert_eq!(cancelled["status"], "cancelled");
1854 assert!(mailbox.poll("r1").is_err());
1855 }
1856
1857 #[test]
1858 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1859 let mailbox = UiDialogMailbox::new();
1860 mailbox.attach();
1861 mailbox.handle(open_args("r1", "c1")).unwrap();
1862 mailbox.handle(open_args("r2", "c2")).unwrap();
1863 mailbox.cancel_tool_call("c1");
1864 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1865 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1866 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1867 assert_eq!(
1868 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1869 "cancelled"
1870 );
1871 assert!(mailbox.take_pending().is_none());
1872 }
1873
1874 #[test]
1875 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1876 let mailbox = UiDialogMailbox::new();
1877 mailbox.attach();
1878 mailbox.handle(open_args("r1", "c1")).unwrap();
1879 mailbox.handle(open_args("r2", "c2")).unwrap();
1880
1881 let first = mailbox.take_pending().unwrap().request_id;
1882 let second = mailbox.take_pending().unwrap().request_id;
1883 assert_ne!(first, second);
1884
1885 mailbox
1887 .respond(&second, serde_json::json!("second"))
1888 .unwrap();
1889 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1890 let second_answer = mailbox.poll(&second).unwrap();
1891 assert_eq!(second_answer["answer"], "second");
1892
1893 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1894 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1895 }
1896
1897 #[test]
1898 fn ui_dialog_detach_cancels_outstanding() {
1899 let mailbox = UiDialogMailbox::new();
1900 mailbox.attach();
1901 mailbox.handle(open_args("r1", "c1")).unwrap();
1902 mailbox.detach();
1903 assert!(!mailbox.is_attached());
1904 assert_eq!(mailbox.pending_len(), 0);
1905 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1907 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1908 }
1909
1910 #[test]
1911 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1912 let mailbox = UiDialogMailbox::new();
1913 mailbox.attach();
1914 mailbox.handle(open_args("r1", "c1")).unwrap();
1915 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1916 assert_eq!(again["status"], "pending");
1917 assert_eq!(mailbox.pending_len(), 1);
1918 mailbox.take_pending().unwrap();
1919 assert!(mailbox.take_pending().is_none());
1920 }
1921
1922 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1926 async fn v2_dispatch_routes_ui_dialog_action_17() {
1927 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1928 prompt: String::new(),
1929 saw: Mutex::new(Vec::new()),
1930 });
1931 let bridge = ActionBridge::with_ui_dialog(
1932 tokio::runtime::Handle::current(),
1933 host,
1934 UiDialogMailbox::new(),
1935 );
1936 let user_data = bridge.user_data();
1937
1938 let mut out = StbString::empty();
1939 let rc = trampoline_runtime_action(
1940 17,
1941 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1942 &mut out,
1943 user_data,
1944 );
1945 assert_eq!(rc, 1);
1946 let payload: serde_json::Value = serde_json::from_str(
1947 &unsafe {
1948 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1949 }
1950 .to_string_lossy(),
1951 )
1952 .expect("structured error JSON");
1953 assert!(
1954 payload["error"]
1955 .as_str()
1956 .is_some_and(|error| error.contains("interactive UI")),
1957 "unexpected payload: {payload}"
1958 );
1959
1960 let mut out = StbString::empty();
1963 assert_eq!(
1964 trampoline_runtime_action(26, StbStringRef::from_str("{}"), &mut out, user_data),
1965 2
1966 );
1967 drop(unsafe {
1969 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1970 });
1971 }
1972
1973 #[tokio::test(flavor = "current_thread")]
1974 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1975 let host = Arc::new(MockHost {
1976 prompt: String::new(),
1977 saw: Mutex::new(Vec::new()),
1978 });
1979 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1980 let a = root.for_plugin("a");
1981 let b = root.for_plugin("b");
1982 let mailbox = root.extension_status_mailbox();
1983 for plugin in [&a, &b] {
1984 let mut out = StbString::empty();
1985 assert_eq!(
1986 trampoline_runtime_action(
1987 22,
1988 StbStringRef::from_str(
1989 r#"{"op":"register","id":"same","slot":"footer.after"}"#
1990 ),
1991 &mut out,
1992 plugin.user_data()
1993 ),
1994 0
1995 );
1996 drop(unsafe {
1998 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1999 });
2000 }
2001 assert_eq!(mailbox.panels().len(), 2);
2002 drop(a);
2003 assert_eq!(mailbox.panels().len(), 1);
2004 root.invalidate();
2005 assert!(mailbox.panels().is_empty());
2006 }
2007
2008 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2011 async fn v2_dispatch_routes_set_status_action_18() {
2012 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2013 prompt: String::new(),
2014 saw: Mutex::new(Vec::new()),
2015 });
2016 let status = crate::status::ExtensionStatusMailbox::new();
2017 let bridge = ActionBridge::with_reload_and_ui(
2018 tokio::runtime::Handle::current(),
2019 host,
2020 Arc::new(|| Box::pin(async {})),
2021 UiDialogMailbox::new(),
2022 status.clone(),
2023 crate::editor_text::EditorTextMailbox::new(),
2024 );
2025 let user_data = bridge.user_data();
2026
2027 let mut out = StbString::empty();
2028 let rc = trampoline_runtime_action(
2029 18,
2030 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2031 &mut out,
2032 user_data,
2033 );
2034 assert_eq!(rc, 0);
2035 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2036 drop(unsafe {
2038 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2039 });
2040
2041 let mut out = StbString::empty();
2043 assert_eq!(
2044 trampoline_runtime_action(
2045 18,
2046 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2047 &mut out,
2048 user_data
2049 ),
2050 0
2051 );
2052 assert_eq!(status.text(), "");
2053 drop(unsafe {
2055 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2056 });
2057
2058 let mut out = StbString::empty();
2060 assert_eq!(
2061 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2062 1
2063 );
2064 drop(unsafe {
2066 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2067 });
2068 }
2069}
2070
2071impl Drop for ActionBridge {
2072 fn drop(&mut self) {
2073 bridges()
2074 .lock()
2075 .unwrap_or_else(|e| e.into_inner())
2076 .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2077 if self.invocation.is_some() {
2078 return;
2079 }
2080 if let Some((owner, _)) = &self.plugin_identity {
2081 self.ext_status.remove_owner(*owner);
2082 self.host.cancel_child_agents(*owner);
2083 self.host.cancel_side_questions(*owner);
2084 }
2085 }
2086}
2087
2088#[cfg(test)]
2089mod runtime_tests;