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(
500 &self,
501 _args: serde_json::Value,
502 ) -> Result<serde_json::Value, String> {
503 Err("session branch lookup is not configured".to_string())
504 }
505}
506
507pub struct ActionBridge {
534 invocation: Option<(bool, String, tokio_util::sync::CancellationToken)>,
536 execution_context: Option<serde_json::Value>,
537 stdio: crate::StdioLifecycle,
538 plugin_identity: Option<(u64, String)>,
539 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
540 pub(crate) runtime: Handle,
542 pub(crate) host: Arc<dyn RuntimeActionHost>,
544 pub(crate) reload:
546 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
547 ui_dialog: UiDialogMailbox,
550 ext_status: crate::status::ExtensionStatusMailbox,
554 editor_text: crate::editor_text::EditorTextMailbox,
558 active: Arc<AtomicBool>,
561}
562
563impl ActionBridge {
564 pub fn user_data(self: &Arc<Self>) -> *mut c_void {
566 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
567 let mut registry = bridges().lock().unwrap_or_else(|e| e.into_inner());
568 if let Some((&id, _)) = registry
569 .iter()
570 .find(|(_, weak)| weak.as_ptr() == Arc::as_ptr(self))
571 {
572 return id as *mut c_void;
573 }
574 let mut id = NEXT.load(Ordering::Relaxed);
575 loop {
576 let next = id.checked_add(1).expect("runtime handle space exhausted");
577 match NEXT.compare_exchange_weak(id, next, Ordering::Relaxed, Ordering::Relaxed) {
578 Ok(_) => break,
579 Err(current) => id = current,
580 }
581 }
582 registry.insert(id, Arc::downgrade(self));
583 id as *mut c_void
584 }
585 pub(crate) fn resolve_handle(id: usize) -> Option<Arc<Self>> {
586 bridges()
587 .lock()
588 .unwrap_or_else(|e| e.into_inner())
589 .get(&id)
590 .and_then(Weak::upgrade)
591 }
592 pub(crate) fn for_invocation(
593 &self,
594 child: bool,
595 signal: tokio_util::sync::CancellationToken,
596 ) -> Arc<Self> {
597 Arc::new(Self {
598 invocation: Some((child, uuid::Uuid::new_v4().to_string(), signal)),
599 execution_context: rpi_agent::tool_execution_scope::execution_context(),
600 stdio: self.stdio.clone(),
601 plugin_identity: self.plugin_identity.clone(),
602 component_owners: self.component_owners.clone(),
603 runtime: self.runtime.clone(),
604 host: self.host.clone(),
605 reload: self.reload.clone(),
606 ui_dialog: self.ui_dialog.clone(),
607 ext_status: self.ext_status.clone(),
608 editor_text: self.editor_text.clone(),
609 active: self.active.clone(),
610 })
611 }
612 pub(crate) fn invocation_key(&self) -> Option<&str> {
613 self.invocation.as_ref().map(|(_, key, _)| key.as_str())
614 }
615 pub(crate) fn enter_invocation(self: &Arc<Self>) -> InvocationGuard {
616 InvocationGuard {
617 previous: CURRENT_INVOCATION.with(|slot| slot.replace(Some(self.clone()))),
618 _thread_bound: std::marker::PhantomData,
619 }
620 }
621 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
624 Arc::new(Self {
625 invocation: None,
626 execution_context: None,
627 stdio: crate::StdioLifecycle::default(),
628 plugin_identity: None,
629 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
630 runtime,
631 host,
632 reload: None,
633 ui_dialog: UiDialogMailbox::new(),
634 ext_status: crate::status::ExtensionStatusMailbox::new(),
635 editor_text: crate::editor_text::EditorTextMailbox::new(),
636 active: Arc::new(AtomicBool::new(true)),
637 })
638 }
639
640 pub fn with_reload(
643 runtime: Handle,
644 host: Arc<dyn RuntimeActionHost>,
645 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
646 ) -> Arc<Self> {
647 Self::with_reload_and_ui(
648 runtime,
649 host,
650 reload,
651 UiDialogMailbox::new(),
652 crate::status::ExtensionStatusMailbox::new(),
653 crate::editor_text::EditorTextMailbox::new(),
654 )
655 }
656
657 pub fn with_ui_dialog(
659 runtime: Handle,
660 host: Arc<dyn RuntimeActionHost>,
661 ui_dialog: UiDialogMailbox,
662 ) -> Arc<Self> {
663 Arc::new(Self {
664 invocation: None,
665 execution_context: None,
666 stdio: crate::StdioLifecycle::default(),
667 plugin_identity: None,
668 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
669 runtime,
670 host,
671 reload: None,
672 ui_dialog,
673 ext_status: crate::status::ExtensionStatusMailbox::new(),
674 editor_text: crate::editor_text::EditorTextMailbox::new(),
675 active: Arc::new(AtomicBool::new(true)),
676 })
677 }
678
679 pub fn with_reload_and_ui(
681 runtime: Handle,
682 host: Arc<dyn RuntimeActionHost>,
683 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
684 ui_dialog: UiDialogMailbox,
685 ext_status: crate::status::ExtensionStatusMailbox,
686 editor_text: crate::editor_text::EditorTextMailbox,
687 ) -> Arc<Self> {
688 Arc::new(Self {
689 invocation: None,
690 execution_context: None,
691 stdio: crate::StdioLifecycle::default(),
692 plugin_identity: None,
693 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
694 runtime,
695 host,
696 reload: Some(reload),
697 ui_dialog,
698 ext_status,
699 editor_text,
700 active: Arc::new(AtomicBool::new(true)),
701 })
702 }
703
704 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
705 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
706 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
707 self.component_owners
708 .lock()
709 .unwrap_or_else(|e| e.into_inner())
710 .push(owner);
711 Arc::new(Self {
712 invocation: None,
713 execution_context: None,
714 component_owners: self.component_owners.clone(),
715 stdio: self.stdio.clone(),
716 plugin_identity: Some((owner, name.to_owned())),
717 runtime: self.runtime.clone(),
718 host: self.host.clone(),
719 reload: self.reload.clone(),
720 ui_dialog: self.ui_dialog.clone(),
721 ext_status: self.ext_status.clone(),
722 editor_text: self.editor_text.clone(),
723 active: self.active.clone(),
724 })
725 }
726
727 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
728 self.stdio.clone()
729 }
730
731 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
732 let (id, name) = self
733 .plugin_identity
734 .as_ref()
735 .ok_or("missing plugin identity")?;
736 self.stdio.claim(*id, name)
737 }
738
739 pub(crate) fn release_stdio_claim(&self) {
740 if let Some((id, _)) = &self.plugin_identity {
741 self.stdio.release_failed_registration(*id);
742 self.ext_status.remove_owner(*id);
743 }
744 }
745
746 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
748 self.ui_dialog.clone()
749 }
750
751 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
753 self.ext_status.clone()
754 }
755
756 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
758 self.editor_text.clone()
759 }
760
761 pub fn invalidate(&self) {
766 let owners = {
767 let owners = self
768 .component_owners
769 .lock()
770 .unwrap_or_else(|e| e.into_inner());
771 self.active.store(false, Ordering::SeqCst);
772 for owner in owners.iter() {
773 self.ext_status.remove_owner(*owner);
774 }
775 owners.clone()
776 };
777 for owner in owners {
780 self.host.cancel_child_agents(owner);
781 self.host.cancel_side_questions(owner);
782 }
783 }
784
785 pub fn is_active(&self) -> bool {
787 self.active.load(Ordering::SeqCst)
788 }
789
790 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
797 Arc::clone(&self.host)
798 }
799}
800
801#[derive(Clone)]
814pub struct ReloadMailbox {
815 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
816}
817
818impl Default for ReloadMailbox {
819 fn default() -> Self {
820 Self {
821 tx: Arc::new(std::sync::Mutex::new(None)),
822 }
823 }
824}
825
826impl ReloadMailbox {
827 pub fn new() -> Self {
829 Self::default()
830 }
831
832 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
836 *self.tx.lock().unwrap() = Some(tx);
837 }
838
839 pub fn signal(&self) -> Result<(), ()> {
844 let g = self.tx.lock().unwrap();
845 match &*g {
846 Some(tx) => {
847 let _ = tx.send(());
848 Ok(())
849 }
850 None => Err(()),
851 }
852 }
853
854 pub fn clear(&self) {
856 *self.tx.lock().unwrap() = None;
857 }
858}
859
860pub fn reload_callback_from_mailbox(
867 mailbox: ReloadMailbox,
868) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
869 Arc::new(move || {
870 let m = mailbox.clone();
871 Box::pin(async move {
872 let _ = m.signal();
873 })
874 })
875}
876
877async fn dispatch(
888 host: &Arc<dyn RuntimeActionHost>,
889 ui_dialog: &UiDialogMailbox,
890 ext_status: &crate::status::ExtensionStatusMailbox,
891 editor_text: &crate::editor_text::EditorTextMailbox,
892 action: RuntimeActionId,
893 args: serde_json::Value,
894) -> Result<serde_json::Value, String> {
895 match action {
896 RuntimeActionId::SendMessage => host.send_message(args).await,
897 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
898 RuntimeActionId::AppendEntry => host.append_entry(args).await,
899 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
900 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
901 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
902 RuntimeActionId::SetModel => host.set_model(args).await,
903 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
904 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
905 RuntimeActionId::Compact => host.compact(args).await,
906 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
907 RuntimeActionId::NewSession => host.new_session(args).await,
908 RuntimeActionId::Fork => host.fork(args).await,
909 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
910 RuntimeActionId::SwitchSession => host.switch_session(args).await,
911 RuntimeActionId::Reload => host.reload(args).await,
912 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
913 RuntimeActionId::UiDialog => ui_dialog.handle(args),
914 RuntimeActionId::SetStatus => ext_status.handle(args),
917 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
918 RuntimeActionId::SetEditorText => editor_text.handle(args),
921 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
922 RuntimeActionId::GetExecutionContext => host.get_execution_context(args).await,
923 RuntimeActionId::ChildAgent => Err("ChildAgent requires a plugin bridge".into()),
924 RuntimeActionId::SideQuestion => Err("SideQuestion requires a plugin bridge".into()),
925 RuntimeActionId::StdioLifecycle => {
926 Err("stdio lifecycle requires a plugin context".to_owned())
927 }
928 }
929}
930
931pub extern "C" fn trampoline_runtime_action(
955 action_id: u32,
956 args_json: StbStringRef,
957 out: *mut StbString,
958 user_data: *mut c_void,
959) -> i32 {
960 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
961 run_action(
962 action_id,
963 u32::from(RuntimeActionId::SideQuestion),
965 args_json,
966 out,
967 user_data,
968 )
969 }));
970 match outcome {
971 Ok(rc) => rc,
972 Err(_) => {
973 tracing::error!("runtime_action trampoline panicked — reporting host error");
977 if !out.is_null() {
978 unsafe {
979 *out = StbString::from_string(
980 r#"{"error":"runtime_action panicked"}"#.to_string(),
981 );
982 }
983 }
984 1
985 }
986 }
987}
988
989fn run_action(
991 action_id: u32,
992 max_action_id: u32,
993 args_json: StbStringRef,
994 out: *mut StbString,
995 user_data: *mut c_void,
996) -> i32 {
997 let action = match RuntimeActionId::try_from(action_id) {
1001 Ok(action) if action_id <= max_action_id => action,
1002 Ok(_) | Err(_) => {
1003 if !out.is_null() {
1004 let json = serde_json::json!({
1005 "error": format!("unknown runtime action id {action_id}")
1006 })
1007 .to_string();
1008 unsafe {
1012 *out = StbString::from_string(json);
1013 }
1014 }
1015 return 2;
1016 }
1017 };
1018
1019 if user_data.is_null() {
1020 return -1;
1021 }
1022 let invocation = CURRENT_INVOCATION.with(|slot| slot.borrow().clone());
1025 let invocation_thread = invocation.is_some();
1026 let bridge = invocation.or_else(|| ActionBridge::resolve_handle(user_data as usize));
1027 let Some(bridge) = bridge else {
1028 if !out.is_null() {
1029 unsafe {
1031 *out = StbString::from_string("{\"error\":\"expired runtime handle\"}".into());
1032 }
1033 }
1034 return 1;
1035 };
1036
1037 if !bridge.is_active() {
1045 if out.is_null() {
1046 return 1;
1047 }
1048 let json = serde_json::json!({
1049 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
1050 })
1051 .to_string();
1052 unsafe {
1053 *out = StbString::from_string(json);
1054 }
1055 return 1;
1056 }
1057
1058 let args_str = unsafe { args_json.as_str() };
1061 let mut args: serde_json::Value = if args_str.is_empty() {
1062 serde_json::Value::Object(serde_json::Map::new())
1063 } else {
1064 serde_json::from_str(args_str)
1065 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
1066 };
1067
1068 let runtime_key = args
1069 .as_object_mut()
1070 .and_then(|args| args.remove("__rpiRuntimeKey"));
1071
1072 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
1077 let host = Arc::clone(&bridge.host);
1081 let ui_dialog = bridge.ui_dialog.clone();
1082 let ext_status = bridge.ext_status.clone();
1083 let editor_text = bridge.editor_text.clone();
1084 let reload_cb = bridge.reload.clone();
1085 let execution_context = bridge.execution_context.clone();
1086 if action == RuntimeActionId::StdioLifecycle {
1087 let result = bridge.stdio.handle(
1088 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
1089 args,
1090 );
1091 let _ = tx.send(result);
1092 } else if action == RuntimeActionId::UiComponent {
1093 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1094 let _owners = bridge
1095 .component_owners
1096 .lock()
1097 .unwrap_or_else(|e| e.into_inner());
1098 let result = if bridge.is_active() {
1099 ext_status.component(owner, args)
1100 } else {
1101 Err("stale plugin bridge".into())
1102 };
1103 let _ = tx.send(result);
1104 } else if action == RuntimeActionId::SideQuestion {
1105 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1106 if owner == 0 || bridge.invocation.is_some() {
1107 let _ = tx.send(Err(
1108 "SideQuestion requires a plugin command, not a tool invocation".into(),
1109 ));
1110 } else {
1111 let weak = Arc::downgrade(&bridge);
1114 let sink: SideQuestionSink = Arc::new(move |args| {
1115 let bridge = weak.upgrade().ok_or("expired side question bridge")?;
1116 let _owners = bridge
1117 .component_owners
1118 .lock()
1119 .unwrap_or_else(|e| e.into_inner());
1120 if !bridge.is_active() {
1121 return Err("stale side question bridge".into());
1122 }
1123 if args["op"] == "check" {
1124 return Ok(serde_json::json!({"ok":true}));
1125 }
1126 bridge.ext_status.component(owner, args)
1127 });
1128 bridge.runtime.spawn(async move {
1129 let _ = tx.send(host.side_question(owner, args, sink).await);
1130 });
1131 }
1132 } else if action == RuntimeActionId::ChildAgent {
1133 let cleanup = matches!(
1138 args.get("op").and_then(serde_json::Value::as_str),
1139 Some("poll" | "cancel" | "dispose")
1140 );
1141 let allowed = bridge
1142 .invocation
1143 .as_ref()
1144 .is_some_and(|(child, key, signal)| {
1145 !child
1146 && (cleanup || !signal.is_cancelled())
1147 && (invocation_thread
1148 || runtime_key.as_ref().and_then(serde_json::Value::as_str)
1149 == Some(key.as_str()))
1150 });
1151 if !allowed {
1152 let _ = tx.send(Err("ChildAgent requires a live parent tool invocation; recursive child agents are not allowed".into()));
1153 } else {
1154 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
1155 let signal = bridge
1156 .invocation
1157 .as_ref()
1158 .expect("authorized invocation")
1159 .2
1160 .clone();
1161 bridge.runtime.spawn(async move {
1162 let result = rpi_agent::tool_execution_scope::with_invocation_signal(
1163 signal,
1164 host.child_agent(owner, args),
1165 )
1166 .await;
1167 let _ = tx.send(result);
1168 });
1169 }
1170 } else if action == RuntimeActionId::SetStatus {
1171 std::thread::spawn(move || {
1176 let _ = tx.send(ext_status.handle(args));
1177 });
1178 } else if action == RuntimeActionId::SetEditorText {
1179 std::thread::spawn(move || {
1182 let _ = tx.send(editor_text.handle(args));
1183 });
1184 } else if action == RuntimeActionId::UiDialog {
1185 std::thread::spawn(move || {
1191 let _ = tx.send(ui_dialog.handle(args));
1192 });
1193 } else {
1194 bridge.runtime.spawn(async move {
1195 let mut r = if action == RuntimeActionId::Reload {
1196 if let Some(cb) = reload_cb {
1197 cb().await;
1198 Ok(serde_json::Value::Null)
1199 } else {
1200 host.reload(args).await
1201 }
1202 } else {
1203 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
1204 };
1205 if action == RuntimeActionId::GetExecutionContext {
1206 if let (Ok(context), Some(serde_json::Value::Object(fields))) =
1207 (&mut r, execution_context)
1208 {
1209 if let Some(object) = context.as_object_mut() {
1210 object.extend(fields);
1211 }
1212 }
1213 }
1214 let _ = tx.send(r);
1217 });
1218 }
1219
1220 let received = if Handle::try_current()
1226 .is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
1227 {
1228 tokio::task::block_in_place(|| rx.recv())
1229 } else {
1230 rx.recv()
1231 };
1232 let result = match received {
1233 Ok(r) => r,
1234 Err(_) => {
1235 return -2;
1240 }
1241 };
1242
1243 if out.is_null() {
1246 return match result {
1248 Ok(_) => 0,
1249 Err(_) => 1,
1250 };
1251 }
1252
1253 let (rc, payload) = match result {
1254 Ok(value) => {
1255 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1256 (0, json)
1257 }
1258 Err(msg) => {
1259 let json = serde_json::json!({ "error": msg }).to_string();
1260 (1, json)
1261 }
1262 };
1263 unsafe {
1269 *out = StbString::from_string(payload);
1270 }
1271 rc
1272}
1273
1274#[cfg(test)]
1283mod tests {
1284 use super::*;
1285 use std::sync::Mutex;
1286
1287 #[tokio::test(flavor = "current_thread")]
1288 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1289 let master = ActionBridge::new(
1290 Handle::current(),
1291 Arc::new(MockHost {
1292 prompt: String::new(),
1293 saw: Mutex::new(Vec::new()),
1294 }),
1295 );
1296 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1297 let owner = crate::HostApi::with_action_bridge(
1298 "owner",
1299 crate::ExtensionRegistry::new(),
1300 diag.clone(),
1301 master.clone(),
1302 );
1303 let other = crate::HostApi::with_action_bridge(
1304 "other",
1305 crate::ExtensionRegistry::new(),
1306 diag,
1307 master.clone(),
1308 );
1309 let vtable = owner.build_vtable();
1310 let claim = r#"{"ownsStdio":true}"#;
1311 {
1312 let _registration = crate::enter_current_api(&owner);
1313 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1314 }
1315 let other_vtable = other.build_vtable();
1316 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1317 let mut out = StbString::empty();
1318 assert_eq!(
1320 (other_vtable.runtime_action)(
1321 RuntimeActionId::StdioLifecycle as u32,
1322 finish,
1323 &mut out,
1324 other_vtable.user_data
1325 ),
1326 1
1327 );
1328 unsafe { (other_vtable.free_string)(out) };
1330 assert_eq!(
1331 (vtable.runtime_action)(
1332 RuntimeActionId::StdioLifecycle as u32,
1333 finish,
1334 &mut out,
1335 vtable.user_data
1336 ),
1337 0
1338 );
1339 unsafe { (vtable.free_string)(out) };
1341 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1342 {
1343 let _registration = crate::enter_current_api(&other);
1344 assert_ne!(
1345 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1346 0
1347 );
1348 }
1349 assert!(master
1350 .stdio_lifecycle()
1351 .validate()
1352 .unwrap_err()
1353 .contains("owner"));
1354 }
1355
1356 struct MockHost {
1361 prompt: String,
1362 saw: Mutex<Vec<RuntimeActionId>>,
1363 }
1364
1365 #[test]
1366 fn runtime_action_from_a_tokio_worker_does_not_block_its_dispatch() {
1367 const CHILD: &str = "RPI_RUNTIME_ACTION_WORKER_REGRESSION_CHILD";
1368 if std::env::var_os(CHILD).is_some() {
1369 let runtime = tokio::runtime::Builder::new_multi_thread()
1370 .worker_threads(1)
1371 .enable_all()
1372 .build()
1373 .unwrap();
1374 runtime.block_on(async {
1375 tokio::spawn(async {
1378 let host = Arc::new(MockHost {
1379 prompt: "worker callback".into(),
1380 saw: Mutex::new(Vec::new()),
1381 });
1382 let bridge = ActionBridge::new(Handle::current(), host);
1383 let mut out = StbString::empty();
1384 assert_eq!(
1385 trampoline_runtime_action(
1386 RuntimeActionId::GetSystemPrompt.into(),
1387 StbStringRef::from_str("{}"),
1388 &mut out,
1389 bridge.user_data(),
1390 ),
1391 0
1392 );
1393 let text = unsafe {
1395 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1396 out,
1397 Some(crate::host_free_string),
1398 )
1399 }
1400 .to_string_lossy();
1401 assert_eq!(
1402 serde_json::from_str::<serde_json::Value>(&text).unwrap()["prompt"],
1403 "worker callback"
1404 );
1405 })
1406 .await
1407 .unwrap();
1408 });
1409 return;
1410 }
1411 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1414 .args([
1415 "--exact",
1416 "actions::tests::runtime_action_from_a_tokio_worker_does_not_block_its_dispatch",
1417 "--nocapture",
1418 ])
1419 .env(CHILD, "1")
1420 .stdout(std::process::Stdio::piped())
1421 .stderr(std::process::Stdio::piped())
1422 .spawn()
1423 .unwrap();
1424 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1425 while child.try_wait().unwrap().is_none() {
1426 if std::time::Instant::now() >= deadline {
1427 let _ = child.kill();
1428 let _ = child.wait();
1429 panic!("runtime action blocked the worker needed by its own dispatch");
1430 }
1431 std::thread::sleep(std::time::Duration::from_millis(10));
1432 }
1433 let output = child.wait_with_output().unwrap();
1434 assert!(
1435 output.status.success(),
1436 "{}\n{}",
1437 String::from_utf8_lossy(&output.stdout),
1438 String::from_utf8_lossy(&output.stderr)
1439 );
1440 }
1441
1442 #[async_trait::async_trait]
1443 impl RuntimeActionHost for MockHost {
1444 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1445 unreachable!("not under test")
1446 }
1447 async fn send_user_message(
1448 &self,
1449 _: serde_json::Value,
1450 ) -> Result<serde_json::Value, String> {
1451 unreachable!("not under test")
1452 }
1453 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1454 unreachable!("not under test")
1455 }
1456 async fn set_session_name(
1457 &self,
1458 _: serde_json::Value,
1459 ) -> Result<serde_json::Value, String> {
1460 unreachable!("not under test")
1461 }
1462 async fn get_active_tools(
1463 &self,
1464 _: serde_json::Value,
1465 ) -> Result<serde_json::Value, String> {
1466 unreachable!("not under test")
1467 }
1468 async fn set_active_tools(
1469 &self,
1470 _: serde_json::Value,
1471 ) -> Result<serde_json::Value, String> {
1472 unreachable!("not under test")
1473 }
1474 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1475 unreachable!("not under test")
1476 }
1477 async fn get_thinking_level(
1478 &self,
1479 _: serde_json::Value,
1480 ) -> Result<serde_json::Value, String> {
1481 unreachable!("not under test")
1482 }
1483 async fn set_thinking_level(
1484 &self,
1485 _: serde_json::Value,
1486 ) -> Result<serde_json::Value, String> {
1487 unreachable!("not under test")
1488 }
1489 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1490 unreachable!("not under test")
1491 }
1492 async fn get_system_prompt(
1493 &self,
1494 _: serde_json::Value,
1495 ) -> Result<serde_json::Value, String> {
1496 self.saw
1497 .lock()
1498 .unwrap()
1499 .push(RuntimeActionId::GetSystemPrompt);
1500 Ok(serde_json::json!({ "prompt": self.prompt }))
1501 }
1502 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1503 unreachable!("not under test")
1504 }
1505 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1506 unreachable!("not under test")
1507 }
1508 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1509 unreachable!("not under test")
1510 }
1511 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1512 unreachable!("not under test")
1513 }
1514 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1515 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1516 Ok(serde_json::Value::Null)
1517 }
1518 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1519 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1520 Ok(serde_json::json!({ "value": true }))
1521 }
1522 }
1523
1524 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1528 async fn trampoline_round_trips_get_system_prompt() {
1529 let host = Arc::new(MockHost {
1530 prompt: "hello from host".to_string(),
1531 saw: Mutex::new(Vec::new()),
1532 });
1533 let host_for_assert = Arc::clone(&host);
1534 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1535 let runtime = tokio::runtime::Handle::current();
1536 let bridge = ActionBridge::new(runtime, host_dyn);
1537 let user_data = bridge.user_data();
1538
1539 let args_str = "{}";
1541 let args_ref = StbStringRef::from_str(args_str);
1542
1543 let mut out = StbString::empty();
1544 let rc = trampoline_runtime_action(
1545 RuntimeActionId::GetSystemPrompt.into(),
1546 args_ref,
1547 &mut out as *mut StbString,
1548 user_data,
1549 );
1550 assert_eq!(rc, 0, "success return code");
1551
1552 let json_text = unsafe {
1555 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1556 }
1557 .to_string_lossy();
1558 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1559 assert_eq!(parsed["prompt"], "hello from host");
1560
1561 let saw = host_for_assert.saw.lock().unwrap().clone();
1564 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1565 }
1566
1567 #[tokio::test]
1573 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1574 let host = Arc::new(MockHost {
1575 prompt: String::new(),
1576 saw: Mutex::new(Vec::new()),
1577 });
1578 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1579 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1580 let mailbox = bridge.editor_text_mailbox();
1581 let user_data = bridge.user_data();
1582
1583 let args_ref =
1584 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1585 let mut out = StbString::empty();
1586 let rc = trampoline_runtime_action(
1587 RuntimeActionId::SetEditorText.into(),
1588 args_ref,
1589 &mut out as *mut StbString,
1590 user_data,
1591 );
1592 assert_eq!(rc, 0, "success return code");
1593
1594 let parsed: serde_json::Value = serde_json::from_str(
1596 &unsafe {
1597 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1598 }
1599 .to_string_lossy(),
1600 )
1601 .expect("valid json");
1602 assert_eq!(parsed["ok"], serde_json::json!(true));
1603 assert_eq!(parsed["chars"], serde_json::json!(10));
1604
1605 let edit = mailbox.take_pending().expect("queued edit");
1607 assert_eq!(edit.text, "from voice");
1608 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1609 assert_eq!(edit.auto_send_ms, Some(2000));
1610 assert!(mailbox.take_pending().is_none());
1611 }
1612
1613 #[tokio::test]
1614 async fn trampoline_null_user_data_returns_minus_one() {
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 std::ptr::null_mut(),
1622 );
1623 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1624 }
1625
1626 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1627 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1628 use std::sync::atomic::{AtomicUsize, Ordering};
1632 let reload_calls = Arc::new(AtomicUsize::new(0));
1633 let reload_calls_for_cb = Arc::clone(&reload_calls);
1634 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1635 Arc::new(move || {
1636 let c = Arc::clone(&reload_calls_for_cb);
1637 Box::pin(async move {
1638 c.fetch_add(1, Ordering::SeqCst);
1639 })
1640 });
1641 let host = Arc::new(MockHost {
1642 prompt: String::new(),
1643 saw: Mutex::new(Vec::new()),
1644 });
1645 let host_for_assert = Arc::clone(&host);
1646 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1647 let runtime = tokio::runtime::Handle::current();
1648 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1649 let user_data = bridge.user_data();
1650
1651 let args_ref = StbStringRef::from_str("{}");
1652 let mut out = StbString::empty();
1653 let rc = trampoline_runtime_action(
1654 RuntimeActionId::Reload.into(),
1655 args_ref,
1656 &mut out as *mut StbString,
1657 user_data,
1658 );
1659 assert_eq!(rc, 0);
1660 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1662 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1663 drop(unsafe {
1665 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1666 });
1667 }
1668
1669 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1674 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1675 let host = Arc::new(MockHost {
1676 prompt: String::new(),
1677 saw: Mutex::new(Vec::new()),
1678 });
1679 let host_for_assert = Arc::clone(&host);
1680 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1681 let runtime = tokio::runtime::Handle::current();
1682 let bridge = ActionBridge::new(runtime, host_dyn);
1683 let user_data = bridge.user_data();
1684
1685 bridge.invalidate();
1687 assert!(!bridge.is_active());
1688
1689 let args_ref = StbStringRef::from_str("{}");
1690 let mut out = StbString::empty();
1691 let rc = trampoline_runtime_action(
1692 RuntimeActionId::GetSystemPrompt.into(),
1693 args_ref,
1694 &mut out as *mut StbString,
1695 user_data,
1696 );
1697 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1699 let json_text = unsafe {
1701 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1702 }
1703 .to_string_lossy();
1704 assert!(
1705 json_text.contains("stale"),
1706 "stale-bridge error payload: {json_text}"
1707 );
1708
1709 assert!(
1710 host_for_assert.saw.lock().unwrap().is_empty(),
1711 "host dispatch must NOT run on a stale bridge"
1712 );
1713 }
1714
1715 #[tokio::test]
1716 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1717 let host = Arc::new(MockHost {
1718 prompt: String::new(),
1719 saw: Mutex::new(Vec::new()),
1720 });
1721 let host_for_assert = Arc::clone(&host);
1722 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1723 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1724 let user_data = bridge.user_data();
1725
1726 let mut out = StbString::empty();
1727 let rc = trampoline_runtime_action(
1728 0xFFFF_FFFE,
1729 StbStringRef::from_str("{}"),
1730 &mut out,
1731 user_data,
1732 );
1733
1734 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1735 let payload: serde_json::Value = serde_json::from_str(
1736 &unsafe {
1737 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1738 }
1739 .to_string_lossy(),
1740 )
1741 .expect("structured error JSON");
1742 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1743
1744 assert!(
1745 host_for_assert.saw.lock().unwrap().is_empty(),
1746 "unknown action id must not reach host dispatch"
1747 );
1748 }
1749
1750 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1751 async fn v2_trampoline_accepts_new_action() {
1752 let host = Arc::new(MockHost {
1753 prompt: "v2".to_string(),
1754 saw: Mutex::new(Vec::new()),
1755 });
1756 let host_for_assert = Arc::clone(&host);
1757 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1758 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1759 let user_data = bridge.user_data();
1760
1761 let mut v2_out = StbString::empty();
1762 assert_eq!(
1763 trampoline_runtime_action(
1764 RuntimeActionId::GetCliFlag.into(),
1765 StbStringRef::from_str(r#"{"name":"flag"}"#),
1766 &mut v2_out,
1767 user_data,
1768 ),
1769 0
1770 );
1771 drop(unsafe {
1773 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1774 });
1775
1776 assert_eq!(
1777 *host_for_assert.saw.lock().unwrap(),
1778 vec![RuntimeActionId::GetCliFlag]
1779 );
1780 }
1781
1782 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1787 async fn reload_mailbox_signals_installed_receiver() {
1788 let mailbox = ReloadMailbox::new();
1789 assert!(matches!(mailbox.signal(), Err(())));
1791
1792 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1793 mailbox.install(tx);
1794 let cb = reload_callback_from_mailbox(mailbox.clone());
1795 cb().await;
1796 assert_eq!(
1797 rx.recv().await,
1798 Some(()),
1799 "installed receiver saw the signal"
1800 );
1801 mailbox.clear();
1802 }
1803
1804 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1807 serde_json::json!({
1808 "op": "open",
1809 "requestId": id,
1810 "toolCallId": tool_call,
1811 "ui": {"kind": "selector", "questions": [
1812 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1813 ]},
1814 })
1815 }
1816
1817 #[test]
1820 fn ui_dialog_open_requires_an_attached_ui() {
1821 let mailbox = UiDialogMailbox::new();
1822 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1823 assert!(error.contains("interactive UI"), "got: {error}");
1824 }
1825
1826 #[test]
1827 fn ui_dialog_open_poll_answer_round_trip() {
1828 let mailbox = UiDialogMailbox::new();
1829 mailbox.attach();
1830
1831 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1832 assert_eq!(opened["status"], "pending");
1833 assert_eq!(opened["toolCallId"], "c1");
1834
1835 let request = mailbox.take_pending().expect("request visible to TUI");
1836 assert_eq!(request.request_id, "r1");
1837 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1838 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1840 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1841
1842 mailbox
1843 .respond("r1", serde_json::json!("7890"))
1844 .expect("respond");
1845 let answered = mailbox.poll("r1").unwrap();
1846 assert_eq!(answered["status"], "answered");
1847 assert_eq!(answered["answer"], "7890");
1848 assert!(mailbox.poll("r1").is_err());
1850 }
1851
1852 #[test]
1853 fn ui_dialog_cancel_is_reported_once() {
1854 let mailbox = UiDialogMailbox::new();
1855 mailbox.attach();
1856 mailbox.handle(open_args("r1", "c1")).unwrap();
1857 mailbox.cancel("r1").unwrap();
1858 let cancelled = mailbox.poll("r1").unwrap();
1859 assert_eq!(cancelled["status"], "cancelled");
1860 assert!(mailbox.poll("r1").is_err());
1861 }
1862
1863 #[test]
1864 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1865 let mailbox = UiDialogMailbox::new();
1866 mailbox.attach();
1867 mailbox.handle(open_args("r1", "c1")).unwrap();
1868 mailbox.handle(open_args("r2", "c2")).unwrap();
1869 mailbox.cancel_tool_call("c1");
1870 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1871 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1872 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1873 assert_eq!(
1874 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1875 "cancelled"
1876 );
1877 assert!(mailbox.take_pending().is_none());
1878 }
1879
1880 #[test]
1881 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1882 let mailbox = UiDialogMailbox::new();
1883 mailbox.attach();
1884 mailbox.handle(open_args("r1", "c1")).unwrap();
1885 mailbox.handle(open_args("r2", "c2")).unwrap();
1886
1887 let first = mailbox.take_pending().unwrap().request_id;
1888 let second = mailbox.take_pending().unwrap().request_id;
1889 assert_ne!(first, second);
1890
1891 mailbox
1893 .respond(&second, serde_json::json!("second"))
1894 .unwrap();
1895 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1896 let second_answer = mailbox.poll(&second).unwrap();
1897 assert_eq!(second_answer["answer"], "second");
1898
1899 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1900 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1901 }
1902
1903 #[test]
1904 fn ui_dialog_detach_cancels_outstanding() {
1905 let mailbox = UiDialogMailbox::new();
1906 mailbox.attach();
1907 mailbox.handle(open_args("r1", "c1")).unwrap();
1908 mailbox.detach();
1909 assert!(!mailbox.is_attached());
1910 assert_eq!(mailbox.pending_len(), 0);
1911 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1913 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1914 }
1915
1916 #[test]
1917 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1918 let mailbox = UiDialogMailbox::new();
1919 mailbox.attach();
1920 mailbox.handle(open_args("r1", "c1")).unwrap();
1921 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1922 assert_eq!(again["status"], "pending");
1923 assert_eq!(mailbox.pending_len(), 1);
1924 mailbox.take_pending().unwrap();
1925 assert!(mailbox.take_pending().is_none());
1926 }
1927
1928 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1932 async fn v2_dispatch_routes_ui_dialog_action_17() {
1933 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1934 prompt: String::new(),
1935 saw: Mutex::new(Vec::new()),
1936 });
1937 let bridge = ActionBridge::with_ui_dialog(
1938 tokio::runtime::Handle::current(),
1939 host,
1940 UiDialogMailbox::new(),
1941 );
1942 let user_data = bridge.user_data();
1943
1944 let mut out = StbString::empty();
1945 let rc = trampoline_runtime_action(
1946 17,
1947 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1948 &mut out,
1949 user_data,
1950 );
1951 assert_eq!(rc, 1);
1952 let payload: serde_json::Value = serde_json::from_str(
1953 &unsafe {
1954 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1955 }
1956 .to_string_lossy(),
1957 )
1958 .expect("structured error JSON");
1959 assert!(
1960 payload["error"]
1961 .as_str()
1962 .is_some_and(|error| error.contains("interactive UI")),
1963 "unexpected payload: {payload}"
1964 );
1965
1966 let mut out = StbString::empty();
1969 assert_eq!(
1970 trampoline_runtime_action(26, StbStringRef::from_str("{}"), &mut out, user_data),
1971 2
1972 );
1973 drop(unsafe {
1975 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1976 });
1977 }
1978
1979 #[tokio::test(flavor = "current_thread")]
1980 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1981 let host = Arc::new(MockHost {
1982 prompt: String::new(),
1983 saw: Mutex::new(Vec::new()),
1984 });
1985 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1986 let a = root.for_plugin("a");
1987 let b = root.for_plugin("b");
1988 let mailbox = root.extension_status_mailbox();
1989 for plugin in [&a, &b] {
1990 let mut out = StbString::empty();
1991 assert_eq!(
1992 trampoline_runtime_action(
1993 22,
1994 StbStringRef::from_str(
1995 r#"{"op":"register","id":"same","slot":"footer.after"}"#
1996 ),
1997 &mut out,
1998 plugin.user_data()
1999 ),
2000 0
2001 );
2002 drop(unsafe {
2004 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2005 });
2006 }
2007 assert_eq!(mailbox.panels().len(), 2);
2008 drop(a);
2009 assert_eq!(mailbox.panels().len(), 1);
2010 root.invalidate();
2011 assert!(mailbox.panels().is_empty());
2012 }
2013
2014 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2017 async fn v2_dispatch_routes_set_status_action_18() {
2018 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
2019 prompt: String::new(),
2020 saw: Mutex::new(Vec::new()),
2021 });
2022 let status = crate::status::ExtensionStatusMailbox::new();
2023 let bridge = ActionBridge::with_reload_and_ui(
2024 tokio::runtime::Handle::current(),
2025 host,
2026 Arc::new(|| Box::pin(async {})),
2027 UiDialogMailbox::new(),
2028 status.clone(),
2029 crate::editor_text::EditorTextMailbox::new(),
2030 );
2031 let user_data = bridge.user_data();
2032
2033 let mut out = StbString::empty();
2034 let rc = trampoline_runtime_action(
2035 18,
2036 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
2037 &mut out,
2038 user_data,
2039 );
2040 assert_eq!(rc, 0);
2041 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
2042 drop(unsafe {
2044 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2045 });
2046
2047 let mut out = StbString::empty();
2049 assert_eq!(
2050 trampoline_runtime_action(
2051 18,
2052 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
2053 &mut out,
2054 user_data
2055 ),
2056 0
2057 );
2058 assert_eq!(status.text(), "");
2059 drop(unsafe {
2061 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2062 });
2063
2064 let mut out = StbString::empty();
2066 assert_eq!(
2067 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
2068 1
2069 );
2070 drop(unsafe {
2072 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
2073 });
2074 }
2075}
2076
2077impl Drop for ActionBridge {
2078 fn drop(&mut self) {
2079 bridges()
2080 .lock()
2081 .unwrap_or_else(|e| e.into_inner())
2082 .retain(|_, weak| !std::ptr::eq(weak.as_ptr(), self));
2083 if self.invocation.is_some() {
2084 return;
2085 }
2086 if let Some((owner, _)) = &self.plugin_identity {
2087 self.ext_status.remove_owner(*owner);
2088 self.host.cancel_child_agents(*owner);
2089 self.host.cancel_side_questions(*owner);
2090 }
2091 }
2092}
2093
2094#[cfg(test)]
2095mod runtime_tests;