1use std::collections::{HashMap, VecDeque};
38use std::ffi::c_void;
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::sync::{mpsc, Arc, Mutex};
41
42use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
43use tokio::runtime::Handle;
44
45#[derive(Clone, Debug, PartialEq)]
52pub struct UiDialogRequest {
53 pub request_id: String,
54 pub tool_call_id: Option<String>,
55 pub ui: serde_json::Value,
56 pub raw: serde_json::Value,
57}
58
59struct UiDialogEntry {
60 request: UiDialogRequest,
61 answer: Option<serde_json::Value>,
62 cancelled: bool,
63}
64
65struct UiDialogState {
66 attached: bool,
67 pending: VecDeque<String>,
68 active: HashMap<String, UiDialogEntry>,
69 cancelled_tool_calls: std::collections::HashSet<String>,
70}
71
72#[derive(Clone)]
76pub struct UiDialogMailbox {
77 state: Arc<Mutex<UiDialogState>>,
78}
79
80impl Default for UiDialogMailbox {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl UiDialogMailbox {
87 pub fn new() -> Self {
88 Self {
89 state: Arc::new(Mutex::new(UiDialogState {
90 attached: false,
91 pending: VecDeque::new(),
92 active: HashMap::new(),
93 cancelled_tool_calls: std::collections::HashSet::new(),
94 })),
95 }
96 }
97
98 pub fn attach(&self) {
100 self.state
101 .lock()
102 .expect("ui dialog mailbox poisoned")
103 .attached = true;
104 }
105
106 pub fn detach(&self) {
109 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
110 state.attached = false;
111 state.pending.clear();
112 for entry in state.active.values_mut() {
113 entry.cancelled = true;
114 entry.answer = None;
115 }
116 }
117
118 pub fn is_attached(&self) -> bool {
119 self.state
120 .lock()
121 .expect("ui dialog mailbox poisoned")
122 .attached
123 }
124
125 pub fn take_pending(&self) -> Option<UiDialogRequest> {
129 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
130 while let Some(request_id) = state.pending.pop_front() {
131 if let Some(entry) = state.active.get(&request_id) {
132 if !entry.cancelled && entry.answer.is_none() {
133 return Some(entry.request.clone());
134 }
135 }
136 }
137 None
138 }
139
140 pub fn pending_len(&self) -> usize {
141 self.state
142 .lock()
143 .expect("ui dialog mailbox poisoned")
144 .pending
145 .len()
146 }
147
148 pub fn is_pending(&self, request_id: &str) -> bool {
150 self.state
151 .lock()
152 .expect("ui dialog mailbox poisoned")
153 .active
154 .get(request_id)
155 .is_some_and(|entry| !entry.cancelled && entry.answer.is_none())
156 }
157
158 pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
161 let mut state = self
162 .state
163 .lock()
164 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
165 let entry = state
166 .active
167 .get_mut(request_id)
168 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
169 if entry.cancelled {
170 return Err(format!("UI request `{request_id}` was cancelled"));
171 }
172 if entry.answer.is_none() {
173 entry.answer = Some(answer);
174 }
175 Ok(())
176 }
177
178 pub fn cancel(&self, request_id: &str) -> Result<(), String> {
181 let mut state = self
182 .state
183 .lock()
184 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
185 let entry = state
186 .active
187 .get_mut(request_id)
188 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
189 entry.cancelled = true;
190 entry.answer = None;
191 state.pending.retain(|id| id != request_id);
192 Ok(())
193 }
194
195 pub fn cancel_all(&self) {
197 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
198 state.pending.clear();
199 for entry in state.active.values_mut() {
200 entry.cancelled = true;
201 entry.answer = None;
202 }
203 }
204
205 pub fn cancel_tool_call(&self, tool_call_id: &str) {
208 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
209 state.cancelled_tool_calls.insert(tool_call_id.to_string());
212 for entry in state.active.values_mut() {
213 if entry.request.tool_call_id.as_deref() == Some(tool_call_id) {
214 entry.cancelled = true;
215 entry.answer = None;
216 }
217 }
218 let cancelled: std::collections::HashSet<String> = state
219 .active
220 .iter()
221 .filter(|(_, entry)| entry.cancelled)
222 .map(|(id, _)| id.clone())
223 .collect();
224 state.pending.retain(|id| !cancelled.contains(id));
225 }
226
227 pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
230 let mut state = self
231 .state
232 .lock()
233 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
234 let terminal = match state.active.get(request_id) {
235 Some(entry) if entry.cancelled => Some(serde_json::json!({
236 "status": "cancelled",
237 "requestId": request_id,
238 "toolCallId": entry.request.tool_call_id,
239 })),
240 Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
241 "status": "answered",
242 "requestId": request_id,
243 "toolCallId": entry.request.tool_call_id,
244 "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
245 })),
246 Some(entry) => Some(serde_json::json!({
247 "status": "pending",
248 "requestId": request_id,
249 "toolCallId": entry.request.tool_call_id,
250 })),
251 None => None,
252 };
253 let Some(result) = terminal else {
254 return Err(format!("unknown UI request `{request_id}`"));
255 };
256 if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
257 state.active.remove(request_id);
258 }
259 Ok(result)
260 }
261
262 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
266 let op = args
267 .get("op")
268 .and_then(serde_json::Value::as_str)
269 .unwrap_or("open");
270 let request_id = || {
271 args.get("requestId")
272 .or_else(|| args.get("request_id"))
273 .and_then(serde_json::Value::as_str)
274 .map(str::to_owned)
275 .filter(|id| !id.trim().is_empty())
276 .ok_or_else(|| "missing non-empty `requestId`".to_string())
277 };
278 match op {
279 "open" => {
280 let id = request_id()?;
281 let mut state = self
282 .state
283 .lock()
284 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
285 if !state.attached {
286 return Err("ask_user requires an interactive UI".to_string());
287 }
288 if let Some(entry) = state.active.get(&id) {
289 return Ok(if entry.cancelled {
290 serde_json::json!({"status":"cancelled","requestId":id})
291 } else if let Some(answer) = &entry.answer {
292 serde_json::json!({"status":"answered","requestId":id,"answer":answer})
293 } else {
294 serde_json::json!({"status":"pending","requestId":id})
295 });
296 }
297 let tool_call_id = args
298 .get("toolCallId")
299 .or_else(|| args.get("tool_call_id"))
300 .and_then(serde_json::Value::as_str)
301 .map(str::to_owned);
302 if tool_call_id
303 .as_ref()
304 .is_some_and(|id| state.cancelled_tool_calls.contains(id))
305 {
306 return Ok(serde_json::json!({
307 "status": "cancelled", "requestId": id, "toolCallId": tool_call_id,
308 }));
309 }
310 let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
311 rpi_plugin_sdk::dialogs::validate(&ui, tool_call_id.as_deref())?;
312 state.active.insert(
313 id.clone(),
314 UiDialogEntry {
315 request: UiDialogRequest {
316 request_id: id.clone(),
317 tool_call_id: tool_call_id.clone(),
318 ui,
319 raw: args,
320 },
321 answer: None,
322 cancelled: false,
323 },
324 );
325 state.pending.push_back(id.clone());
326 Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
327 }
328 "poll" => self.poll(&request_id()?),
329 "cancel" => {
330 let id = request_id()?;
331 self.cancel(&id)?;
332 Ok(serde_json::json!({"status":"cancelled","requestId":id}))
333 }
334 "response" | "respond" => {
335 let id = request_id()?;
336 let answer = args
337 .get("answer")
338 .or_else(|| args.get("value"))
339 .cloned()
340 .unwrap_or(serde_json::Value::Null);
341 self.respond(&id, answer)?;
342 Ok(serde_json::json!({"status":"answered","requestId":id}))
343 }
344 other => Err(format!("unknown UI dialog operation `{other}`")),
345 }
346 }
347}
348
349#[async_trait::async_trait]
364pub trait RuntimeActionHost: Send + Sync {
365 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
370 async fn send_user_message(&self, args: serde_json::Value)
372 -> Result<serde_json::Value, String>;
373 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
378 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
380 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
382 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
384 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
386 async fn get_thinking_level(
388 &self,
389 args: serde_json::Value,
390 ) -> Result<serde_json::Value, String>;
391 async fn set_thinking_level(
393 &self,
394 args: serde_json::Value,
395 ) -> Result<serde_json::Value, String>;
396 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
398 async fn get_system_prompt(&self, args: serde_json::Value)
400 -> Result<serde_json::Value, String>;
401 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
403 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
405 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
407 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
409 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
413
414 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
417 Err("CLI flag lookup is not configured".to_string())
418 }
419
420 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
424 Err("ask_user requires an interactive UI".to_string())
425 }
426
427 async fn get_session_branch(
429 &self,
430 _args: serde_json::Value,
431 ) -> Result<serde_json::Value, String> {
432 Err("session branch lookup is not configured".to_string())
433 }
434}
435
436pub struct ActionBridge {
465 stdio: crate::StdioLifecycle,
466 plugin_identity: Option<(u64, String)>,
467 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
468 pub(crate) runtime: Handle,
470 pub(crate) host: Arc<dyn RuntimeActionHost>,
472 pub(crate) reload:
474 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
475 ui_dialog: UiDialogMailbox,
478 ext_status: crate::status::ExtensionStatusMailbox,
482 editor_text: crate::editor_text::EditorTextMailbox,
486 active: Arc<AtomicBool>,
489}
490
491impl ActionBridge {
492 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
495 Arc::new(Self {
496 stdio: crate::StdioLifecycle::default(),
497 plugin_identity: None,
498 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
499 runtime,
500 host,
501 reload: None,
502 ui_dialog: UiDialogMailbox::new(),
503 ext_status: crate::status::ExtensionStatusMailbox::new(),
504 editor_text: crate::editor_text::EditorTextMailbox::new(),
505 active: Arc::new(AtomicBool::new(true)),
506 })
507 }
508
509 pub fn with_reload(
512 runtime: Handle,
513 host: Arc<dyn RuntimeActionHost>,
514 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
515 ) -> Arc<Self> {
516 Self::with_reload_and_ui(
517 runtime,
518 host,
519 reload,
520 UiDialogMailbox::new(),
521 crate::status::ExtensionStatusMailbox::new(),
522 crate::editor_text::EditorTextMailbox::new(),
523 )
524 }
525
526 pub fn with_ui_dialog(
528 runtime: Handle,
529 host: Arc<dyn RuntimeActionHost>,
530 ui_dialog: UiDialogMailbox,
531 ) -> Arc<Self> {
532 Arc::new(Self {
533 stdio: crate::StdioLifecycle::default(),
534 plugin_identity: None,
535 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
536 runtime,
537 host,
538 reload: None,
539 ui_dialog,
540 ext_status: crate::status::ExtensionStatusMailbox::new(),
541 editor_text: crate::editor_text::EditorTextMailbox::new(),
542 active: Arc::new(AtomicBool::new(true)),
543 })
544 }
545
546 pub fn with_reload_and_ui(
548 runtime: Handle,
549 host: Arc<dyn RuntimeActionHost>,
550 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
551 ui_dialog: UiDialogMailbox,
552 ext_status: crate::status::ExtensionStatusMailbox,
553 editor_text: crate::editor_text::EditorTextMailbox,
554 ) -> Arc<Self> {
555 Arc::new(Self {
556 stdio: crate::StdioLifecycle::default(),
557 plugin_identity: None,
558 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
559 runtime,
560 host,
561 reload: Some(reload),
562 ui_dialog,
563 ext_status,
564 editor_text,
565 active: Arc::new(AtomicBool::new(true)),
566 })
567 }
568
569 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
570 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
571 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
572 self.component_owners
573 .lock()
574 .unwrap_or_else(|e| e.into_inner())
575 .push(owner);
576 Arc::new(Self {
577 component_owners: self.component_owners.clone(),
578 stdio: self.stdio.clone(),
579 plugin_identity: Some((owner, name.to_owned())),
580 runtime: self.runtime.clone(),
581 host: self.host.clone(),
582 reload: self.reload.clone(),
583 ui_dialog: self.ui_dialog.clone(),
584 ext_status: self.ext_status.clone(),
585 editor_text: self.editor_text.clone(),
586 active: self.active.clone(),
587 })
588 }
589
590 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
591 self.stdio.clone()
592 }
593
594 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
595 let (id, name) = self
596 .plugin_identity
597 .as_ref()
598 .ok_or("missing plugin identity")?;
599 self.stdio.claim(*id, name)
600 }
601
602 pub(crate) fn release_stdio_claim(&self) {
603 if let Some((id, _)) = &self.plugin_identity {
604 self.stdio.release_failed_registration(*id);
605 self.ext_status.remove_owner(*id);
606 }
607 }
608
609 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
611 self.ui_dialog.clone()
612 }
613
614 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
616 self.ext_status.clone()
617 }
618
619 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
621 self.editor_text.clone()
622 }
623
624 pub fn invalidate(&self) {
629 let owners = self
630 .component_owners
631 .lock()
632 .unwrap_or_else(|e| e.into_inner());
633 self.active.store(false, Ordering::SeqCst);
634 for owner in owners.iter() {
635 self.ext_status.remove_owner(*owner);
636 }
637 }
638
639 pub fn is_active(&self) -> bool {
641 self.active.load(Ordering::SeqCst)
642 }
643
644 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
651 Arc::clone(&self.host)
652 }
653}
654
655#[derive(Clone)]
668pub struct ReloadMailbox {
669 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
670}
671
672impl Default for ReloadMailbox {
673 fn default() -> Self {
674 Self {
675 tx: Arc::new(std::sync::Mutex::new(None)),
676 }
677 }
678}
679
680impl ReloadMailbox {
681 pub fn new() -> Self {
683 Self::default()
684 }
685
686 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
690 *self.tx.lock().unwrap() = Some(tx);
691 }
692
693 pub fn signal(&self) -> Result<(), ()> {
698 let g = self.tx.lock().unwrap();
699 match &*g {
700 Some(tx) => {
701 let _ = tx.send(());
702 Ok(())
703 }
704 None => Err(()),
705 }
706 }
707
708 pub fn clear(&self) {
710 *self.tx.lock().unwrap() = None;
711 }
712}
713
714pub fn reload_callback_from_mailbox(
721 mailbox: ReloadMailbox,
722) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
723 Arc::new(move || {
724 let m = mailbox.clone();
725 Box::pin(async move {
726 let _ = m.signal();
727 })
728 })
729}
730
731async fn dispatch(
742 host: &Arc<dyn RuntimeActionHost>,
743 ui_dialog: &UiDialogMailbox,
744 ext_status: &crate::status::ExtensionStatusMailbox,
745 editor_text: &crate::editor_text::EditorTextMailbox,
746 action: RuntimeActionId,
747 args: serde_json::Value,
748) -> Result<serde_json::Value, String> {
749 match action {
750 RuntimeActionId::SendMessage => host.send_message(args).await,
751 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
752 RuntimeActionId::AppendEntry => host.append_entry(args).await,
753 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
754 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
755 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
756 RuntimeActionId::SetModel => host.set_model(args).await,
757 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
758 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
759 RuntimeActionId::Compact => host.compact(args).await,
760 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
761 RuntimeActionId::NewSession => host.new_session(args).await,
762 RuntimeActionId::Fork => host.fork(args).await,
763 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
764 RuntimeActionId::SwitchSession => host.switch_session(args).await,
765 RuntimeActionId::Reload => host.reload(args).await,
766 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
767 RuntimeActionId::UiDialog => ui_dialog.handle(args),
768 RuntimeActionId::SetStatus => ext_status.handle(args),
771 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
772 RuntimeActionId::SetEditorText => editor_text.handle(args),
775 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
776 RuntimeActionId::StdioLifecycle => {
777 Err("stdio lifecycle requires a plugin context".to_owned())
778 }
779 }
780}
781
782pub extern "C" fn trampoline_runtime_action(
805 action_id: u32,
806 args_json: StbStringRef,
807 out: *mut StbString,
808 user_data: *mut c_void,
809) -> i32 {
810 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
811 run_action(
812 action_id,
813 u32::from(RuntimeActionId::UiComponent),
815 args_json,
816 out,
817 user_data,
818 )
819 }));
820 match outcome {
821 Ok(rc) => rc,
822 Err(_) => {
823 tracing::error!("runtime_action trampoline panicked — reporting host error");
827 if !out.is_null() {
828 unsafe {
829 *out = StbString::from_string(
830 r#"{"error":"runtime_action panicked"}"#.to_string(),
831 );
832 }
833 }
834 1
835 }
836 }
837}
838
839fn run_action(
841 action_id: u32,
842 max_action_id: u32,
843 args_json: StbStringRef,
844 out: *mut StbString,
845 user_data: *mut c_void,
846) -> i32 {
847 let action = match RuntimeActionId::try_from(action_id) {
851 Ok(action) if action_id <= max_action_id => action,
852 Ok(_) | Err(_) => {
853 if !out.is_null() {
854 let json = serde_json::json!({
855 "error": format!("unknown runtime action id {action_id}")
856 })
857 .to_string();
858 unsafe {
862 *out = StbString::from_string(json);
863 }
864 }
865 return 2;
866 }
867 };
868
869 if user_data.is_null() {
870 return -1;
871 }
872 let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
877
878 if !bridge.is_active() {
886 if out.is_null() {
887 return 1;
888 }
889 let json = serde_json::json!({
890 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
891 })
892 .to_string();
893 unsafe {
894 *out = StbString::from_string(json);
895 }
896 return 1;
897 }
898
899 let args_str = unsafe { args_json.as_str() };
902 let args: serde_json::Value = if args_str.is_empty() {
903 serde_json::Value::Object(serde_json::Map::new())
904 } else {
905 serde_json::from_str(args_str)
906 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
907 };
908
909 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
914 let host = Arc::clone(&bridge.host);
918 let ui_dialog = bridge.ui_dialog.clone();
919 let ext_status = bridge.ext_status.clone();
920 let editor_text = bridge.editor_text.clone();
921 let reload_cb = bridge.reload.clone();
922 if action == RuntimeActionId::StdioLifecycle {
923 let result = bridge.stdio.handle(
924 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
925 args,
926 );
927 let _ = tx.send(result);
928 } else if action == RuntimeActionId::UiComponent {
929 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
930 let _owners = bridge
931 .component_owners
932 .lock()
933 .unwrap_or_else(|e| e.into_inner());
934 let result = if bridge.is_active() {
935 ext_status.component(owner, args)
936 } else {
937 Err("stale plugin bridge".into())
938 };
939 let _ = tx.send(result);
940 } else if action == RuntimeActionId::SetStatus {
941 std::thread::spawn(move || {
946 let _ = tx.send(ext_status.handle(args));
947 });
948 } else if action == RuntimeActionId::SetEditorText {
949 std::thread::spawn(move || {
952 let _ = tx.send(editor_text.handle(args));
953 });
954 } else if action == RuntimeActionId::UiDialog {
955 std::thread::spawn(move || {
961 let _ = tx.send(ui_dialog.handle(args));
962 });
963 } else {
964 bridge.runtime.spawn(async move {
965 let r = if action == RuntimeActionId::Reload {
966 if let Some(cb) = reload_cb {
967 cb().await;
968 Ok(serde_json::Value::Null)
969 } else {
970 host.reload(args).await
971 }
972 } else {
973 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
974 };
975 let _ = tx.send(r);
978 });
979 }
980
981 let result = match rx.recv() {
982 Ok(r) => r,
983 Err(_) => {
984 return -2;
989 }
990 };
991
992 if out.is_null() {
995 return match result {
997 Ok(_) => 0,
998 Err(_) => 1,
999 };
1000 }
1001
1002 let (rc, payload) = match result {
1003 Ok(value) => {
1004 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1005 (0, json)
1006 }
1007 Err(msg) => {
1008 let json = serde_json::json!({ "error": msg }).to_string();
1009 (1, json)
1010 }
1011 };
1012 unsafe {
1018 *out = StbString::from_string(payload);
1019 }
1020 rc
1021}
1022
1023#[cfg(test)]
1032mod tests {
1033 use super::*;
1034 use std::sync::Mutex;
1035
1036 #[tokio::test(flavor = "current_thread")]
1037 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1038 let master = ActionBridge::new(
1039 Handle::current(),
1040 Arc::new(MockHost {
1041 prompt: String::new(),
1042 saw: Mutex::new(Vec::new()),
1043 }),
1044 );
1045 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1046 let owner = crate::HostApi::with_action_bridge(
1047 "owner",
1048 crate::ExtensionRegistry::new(),
1049 diag.clone(),
1050 master.clone(),
1051 );
1052 let other = crate::HostApi::with_action_bridge(
1053 "other",
1054 crate::ExtensionRegistry::new(),
1055 diag,
1056 master.clone(),
1057 );
1058 let vtable = owner.build_vtable();
1059 let claim = r#"{"ownsStdio":true}"#;
1060 {
1061 let _registration = crate::enter_current_api(&owner);
1062 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1063 }
1064 let other_vtable = other.build_vtable();
1065 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1066 let mut out = StbString::empty();
1067 assert_eq!(
1069 (other_vtable.runtime_action)(
1070 RuntimeActionId::StdioLifecycle as u32,
1071 finish,
1072 &mut out,
1073 other_vtable.user_data
1074 ),
1075 1
1076 );
1077 unsafe { (other_vtable.free_string)(out) };
1079 assert_eq!(
1080 (vtable.runtime_action)(
1081 RuntimeActionId::StdioLifecycle as u32,
1082 finish,
1083 &mut out,
1084 vtable.user_data
1085 ),
1086 0
1087 );
1088 unsafe { (vtable.free_string)(out) };
1090 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1091 {
1092 let _registration = crate::enter_current_api(&other);
1093 assert_ne!(
1094 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1095 0
1096 );
1097 }
1098 assert!(master
1099 .stdio_lifecycle()
1100 .validate()
1101 .unwrap_err()
1102 .contains("owner"));
1103 }
1104
1105 struct MockHost {
1110 prompt: String,
1111 saw: Mutex<Vec<RuntimeActionId>>,
1112 }
1113
1114 #[async_trait::async_trait]
1115 impl RuntimeActionHost for MockHost {
1116 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1117 unreachable!("not under test")
1118 }
1119 async fn send_user_message(
1120 &self,
1121 _: serde_json::Value,
1122 ) -> Result<serde_json::Value, String> {
1123 unreachable!("not under test")
1124 }
1125 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1126 unreachable!("not under test")
1127 }
1128 async fn set_session_name(
1129 &self,
1130 _: serde_json::Value,
1131 ) -> Result<serde_json::Value, String> {
1132 unreachable!("not under test")
1133 }
1134 async fn get_active_tools(
1135 &self,
1136 _: serde_json::Value,
1137 ) -> Result<serde_json::Value, String> {
1138 unreachable!("not under test")
1139 }
1140 async fn set_active_tools(
1141 &self,
1142 _: serde_json::Value,
1143 ) -> Result<serde_json::Value, String> {
1144 unreachable!("not under test")
1145 }
1146 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1147 unreachable!("not under test")
1148 }
1149 async fn get_thinking_level(
1150 &self,
1151 _: serde_json::Value,
1152 ) -> Result<serde_json::Value, String> {
1153 unreachable!("not under test")
1154 }
1155 async fn set_thinking_level(
1156 &self,
1157 _: serde_json::Value,
1158 ) -> Result<serde_json::Value, String> {
1159 unreachable!("not under test")
1160 }
1161 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1162 unreachable!("not under test")
1163 }
1164 async fn get_system_prompt(
1165 &self,
1166 _: serde_json::Value,
1167 ) -> Result<serde_json::Value, String> {
1168 self.saw
1169 .lock()
1170 .unwrap()
1171 .push(RuntimeActionId::GetSystemPrompt);
1172 Ok(serde_json::json!({ "prompt": self.prompt }))
1173 }
1174 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1175 unreachable!("not under test")
1176 }
1177 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1178 unreachable!("not under test")
1179 }
1180 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1181 unreachable!("not under test")
1182 }
1183 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1184 unreachable!("not under test")
1185 }
1186 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1187 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1188 Ok(serde_json::Value::Null)
1189 }
1190 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1191 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1192 Ok(serde_json::json!({ "value": true }))
1193 }
1194 }
1195
1196 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1205 async fn trampoline_round_trips_get_system_prompt() {
1206 let host = Arc::new(MockHost {
1207 prompt: "hello from host".to_string(),
1208 saw: Mutex::new(Vec::new()),
1209 });
1210 let host_for_assert = Arc::clone(&host);
1211 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1212 let runtime = tokio::runtime::Handle::current();
1213 let bridge = ActionBridge::new(runtime, host_dyn);
1214 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1215
1216 let args_str = "{}";
1218 let args_ref = StbStringRef::from_str(args_str);
1219
1220 let mut out = StbString::empty();
1221 let rc = trampoline_runtime_action(
1222 RuntimeActionId::GetSystemPrompt.into(),
1223 args_ref,
1224 &mut out as *mut StbString,
1225 user_data,
1226 );
1227 assert_eq!(rc, 0, "success return code");
1228
1229 let json_text = unsafe {
1232 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1233 }
1234 .to_string_lossy();
1235 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1236 assert_eq!(parsed["prompt"], "hello from host");
1237
1238 let saw = host_for_assert.saw.lock().unwrap().clone();
1241 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1242 }
1243
1244 #[tokio::test]
1250 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1251 let host = Arc::new(MockHost {
1252 prompt: String::new(),
1253 saw: Mutex::new(Vec::new()),
1254 });
1255 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1256 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1257 let mailbox = bridge.editor_text_mailbox();
1258 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1259
1260 let args_ref =
1261 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1262 let mut out = StbString::empty();
1263 let rc = trampoline_runtime_action(
1264 RuntimeActionId::SetEditorText.into(),
1265 args_ref,
1266 &mut out as *mut StbString,
1267 user_data,
1268 );
1269 assert_eq!(rc, 0, "success return code");
1270
1271 let parsed: serde_json::Value = serde_json::from_str(
1273 &unsafe {
1274 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1275 }
1276 .to_string_lossy(),
1277 )
1278 .expect("valid json");
1279 assert_eq!(parsed["ok"], serde_json::json!(true));
1280 assert_eq!(parsed["chars"], serde_json::json!(10));
1281
1282 let edit = mailbox.take_pending().expect("queued edit");
1284 assert_eq!(edit.text, "from voice");
1285 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1286 assert_eq!(edit.auto_send_ms, Some(2000));
1287 assert!(mailbox.take_pending().is_none());
1288 }
1289
1290 #[tokio::test]
1291 async fn trampoline_null_user_data_returns_minus_one() {
1292 let args_ref = StbStringRef::from_str("{}");
1293 let mut out = StbString::empty();
1294 let rc = trampoline_runtime_action(
1295 RuntimeActionId::GetSystemPrompt.into(),
1296 args_ref,
1297 &mut out as *mut StbString,
1298 std::ptr::null_mut(),
1299 );
1300 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1301 }
1302
1303 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1304 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1305 use std::sync::atomic::{AtomicUsize, Ordering};
1309 let reload_calls = Arc::new(AtomicUsize::new(0));
1310 let reload_calls_for_cb = Arc::clone(&reload_calls);
1311 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1312 Arc::new(move || {
1313 let c = Arc::clone(&reload_calls_for_cb);
1314 Box::pin(async move {
1315 c.fetch_add(1, Ordering::SeqCst);
1316 })
1317 });
1318 let host = Arc::new(MockHost {
1319 prompt: String::new(),
1320 saw: Mutex::new(Vec::new()),
1321 });
1322 let host_for_assert = Arc::clone(&host);
1323 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1324 let runtime = tokio::runtime::Handle::current();
1325 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1326 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1327
1328 let args_ref = StbStringRef::from_str("{}");
1329 let mut out = StbString::empty();
1330 let rc = trampoline_runtime_action(
1331 RuntimeActionId::Reload.into(),
1332 args_ref,
1333 &mut out as *mut StbString,
1334 user_data,
1335 );
1336 assert_eq!(rc, 0);
1337 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1339 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1340 drop(unsafe {
1342 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1343 });
1344 }
1345
1346 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1351 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1352 let host = Arc::new(MockHost {
1353 prompt: String::new(),
1354 saw: Mutex::new(Vec::new()),
1355 });
1356 let host_for_assert = Arc::clone(&host);
1357 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1358 let runtime = tokio::runtime::Handle::current();
1359 let bridge = ActionBridge::new(runtime, host_dyn);
1360 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1361
1362 bridge.invalidate();
1364 assert!(!bridge.is_active());
1365
1366 let args_ref = StbStringRef::from_str("{}");
1367 let mut out = StbString::empty();
1368 let rc = trampoline_runtime_action(
1369 RuntimeActionId::GetSystemPrompt.into(),
1370 args_ref,
1371 &mut out as *mut StbString,
1372 user_data,
1373 );
1374 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1376 let json_text = unsafe {
1378 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1379 }
1380 .to_string_lossy();
1381 assert!(
1382 json_text.contains("stale"),
1383 "stale-bridge error payload: {json_text}"
1384 );
1385
1386 assert!(
1387 host_for_assert.saw.lock().unwrap().is_empty(),
1388 "host dispatch must NOT run on a stale bridge"
1389 );
1390 }
1391
1392 #[tokio::test]
1393 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1394 let host = Arc::new(MockHost {
1395 prompt: String::new(),
1396 saw: Mutex::new(Vec::new()),
1397 });
1398 let host_for_assert = Arc::clone(&host);
1399 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1400 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1401 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1402
1403 let mut out = StbString::empty();
1404 let rc = trampoline_runtime_action(
1405 0xFFFF_FFFE,
1406 StbStringRef::from_str("{}"),
1407 &mut out,
1408 user_data,
1409 );
1410
1411 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1412 let payload: serde_json::Value = serde_json::from_str(
1413 &unsafe {
1414 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1415 }
1416 .to_string_lossy(),
1417 )
1418 .expect("structured error JSON");
1419 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1420
1421 assert!(
1422 host_for_assert.saw.lock().unwrap().is_empty(),
1423 "unknown action id must not reach host dispatch"
1424 );
1425 }
1426
1427 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1428 async fn v2_trampoline_accepts_new_action() {
1429 let host = Arc::new(MockHost {
1430 prompt: "v2".to_string(),
1431 saw: Mutex::new(Vec::new()),
1432 });
1433 let host_for_assert = Arc::clone(&host);
1434 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1435 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1436 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1437
1438 let mut v2_out = StbString::empty();
1439 assert_eq!(
1440 trampoline_runtime_action(
1441 RuntimeActionId::GetCliFlag.into(),
1442 StbStringRef::from_str(r#"{"name":"flag"}"#),
1443 &mut v2_out,
1444 user_data,
1445 ),
1446 0
1447 );
1448 drop(unsafe {
1450 rpi_plugin_sdk::callbacks::OwnedString::from_raw(v2_out, Some(crate::host_free_string))
1451 });
1452
1453 assert_eq!(
1454 *host_for_assert.saw.lock().unwrap(),
1455 vec![RuntimeActionId::GetCliFlag]
1456 );
1457 }
1458
1459 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1464 async fn reload_mailbox_signals_installed_receiver() {
1465 let mailbox = ReloadMailbox::new();
1466 assert!(matches!(mailbox.signal(), Err(())));
1468
1469 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1470 mailbox.install(tx);
1471 let cb = reload_callback_from_mailbox(mailbox.clone());
1472 cb().await;
1473 assert_eq!(
1474 rx.recv().await,
1475 Some(()),
1476 "installed receiver saw the signal"
1477 );
1478 mailbox.clear();
1479 }
1480
1481 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1484 serde_json::json!({
1485 "op": "open",
1486 "requestId": id,
1487 "toolCallId": tool_call,
1488 "ui": {"kind": "selector", "questions": [
1489 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1490 ]},
1491 })
1492 }
1493
1494 #[test]
1497 fn ui_dialog_open_requires_an_attached_ui() {
1498 let mailbox = UiDialogMailbox::new();
1499 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1500 assert!(error.contains("interactive UI"), "got: {error}");
1501 }
1502
1503 #[test]
1504 fn ui_dialog_open_poll_answer_round_trip() {
1505 let mailbox = UiDialogMailbox::new();
1506 mailbox.attach();
1507
1508 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1509 assert_eq!(opened["status"], "pending");
1510 assert_eq!(opened["toolCallId"], "c1");
1511
1512 let request = mailbox.take_pending().expect("request visible to TUI");
1513 assert_eq!(request.request_id, "r1");
1514 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1515 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1517 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1518
1519 mailbox
1520 .respond("r1", serde_json::json!("7890"))
1521 .expect("respond");
1522 let answered = mailbox.poll("r1").unwrap();
1523 assert_eq!(answered["status"], "answered");
1524 assert_eq!(answered["answer"], "7890");
1525 assert!(mailbox.poll("r1").is_err());
1527 }
1528
1529 #[test]
1530 fn ui_dialog_cancel_is_reported_once() {
1531 let mailbox = UiDialogMailbox::new();
1532 mailbox.attach();
1533 mailbox.handle(open_args("r1", "c1")).unwrap();
1534 mailbox.cancel("r1").unwrap();
1535 let cancelled = mailbox.poll("r1").unwrap();
1536 assert_eq!(cancelled["status"], "cancelled");
1537 assert!(mailbox.poll("r1").is_err());
1538 }
1539
1540 #[test]
1541 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1542 let mailbox = UiDialogMailbox::new();
1543 mailbox.attach();
1544 mailbox.handle(open_args("r1", "c1")).unwrap();
1545 mailbox.handle(open_args("r2", "c2")).unwrap();
1546 mailbox.cancel_tool_call("c1");
1547 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1548 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1549 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1550 assert_eq!(
1551 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1552 "cancelled"
1553 );
1554 assert!(mailbox.take_pending().is_none());
1555 }
1556
1557 #[test]
1558 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1559 let mailbox = UiDialogMailbox::new();
1560 mailbox.attach();
1561 mailbox.handle(open_args("r1", "c1")).unwrap();
1562 mailbox.handle(open_args("r2", "c2")).unwrap();
1563
1564 let first = mailbox.take_pending().unwrap().request_id;
1565 let second = mailbox.take_pending().unwrap().request_id;
1566 assert_ne!(first, second);
1567
1568 mailbox
1570 .respond(&second, serde_json::json!("second"))
1571 .unwrap();
1572 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1573 let second_answer = mailbox.poll(&second).unwrap();
1574 assert_eq!(second_answer["answer"], "second");
1575
1576 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1577 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1578 }
1579
1580 #[test]
1581 fn ui_dialog_detach_cancels_outstanding() {
1582 let mailbox = UiDialogMailbox::new();
1583 mailbox.attach();
1584 mailbox.handle(open_args("r1", "c1")).unwrap();
1585 mailbox.detach();
1586 assert!(!mailbox.is_attached());
1587 assert_eq!(mailbox.pending_len(), 0);
1588 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1590 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1591 }
1592
1593 #[test]
1594 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1595 let mailbox = UiDialogMailbox::new();
1596 mailbox.attach();
1597 mailbox.handle(open_args("r1", "c1")).unwrap();
1598 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1599 assert_eq!(again["status"], "pending");
1600 assert_eq!(mailbox.pending_len(), 1);
1601 mailbox.take_pending().unwrap();
1602 assert!(mailbox.take_pending().is_none());
1603 }
1604
1605 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1609 async fn v2_dispatch_routes_ui_dialog_action_17() {
1610 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1611 prompt: String::new(),
1612 saw: Mutex::new(Vec::new()),
1613 });
1614 let bridge = ActionBridge::with_ui_dialog(
1615 tokio::runtime::Handle::current(),
1616 host,
1617 UiDialogMailbox::new(),
1618 );
1619 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1620
1621 let mut out = StbString::empty();
1622 let rc = trampoline_runtime_action(
1623 17,
1624 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1625 &mut out,
1626 user_data,
1627 );
1628 assert_eq!(rc, 1);
1629 let payload: serde_json::Value = serde_json::from_str(
1630 &unsafe {
1631 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1632 }
1633 .to_string_lossy(),
1634 )
1635 .expect("structured error JSON");
1636 assert!(
1637 payload["error"]
1638 .as_str()
1639 .is_some_and(|error| error.contains("interactive UI")),
1640 "unexpected payload: {payload}"
1641 );
1642
1643 let mut out = StbString::empty();
1646 assert_eq!(
1647 trampoline_runtime_action(23, StbStringRef::from_str("{}"), &mut out, user_data),
1648 2
1649 );
1650 drop(unsafe {
1652 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1653 });
1654 }
1655
1656 #[tokio::test(flavor = "current_thread")]
1657 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1658 let host = Arc::new(MockHost {
1659 prompt: String::new(),
1660 saw: Mutex::new(Vec::new()),
1661 });
1662 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1663 let a = root.for_plugin("a");
1664 let b = root.for_plugin("b");
1665 let mailbox = root.extension_status_mailbox();
1666 for plugin in [&a, &b] {
1667 let mut out = StbString::empty();
1668 assert_eq!(
1669 trampoline_runtime_action(
1670 22,
1671 StbStringRef::from_str(
1672 r#"{"op":"register","id":"same","slot":"footer.after"}"#
1673 ),
1674 &mut out,
1675 Arc::as_ptr(plugin) as *mut c_void
1676 ),
1677 0
1678 );
1679 drop(unsafe {
1681 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1682 });
1683 }
1684 assert_eq!(mailbox.panels().len(), 2);
1685 drop(a);
1686 assert_eq!(mailbox.panels().len(), 1);
1687 root.invalidate();
1688 assert!(mailbox.panels().is_empty());
1689 }
1690
1691 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1694 async fn v2_dispatch_routes_set_status_action_18() {
1695 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1696 prompt: String::new(),
1697 saw: Mutex::new(Vec::new()),
1698 });
1699 let status = crate::status::ExtensionStatusMailbox::new();
1700 let bridge = ActionBridge::with_reload_and_ui(
1701 tokio::runtime::Handle::current(),
1702 host,
1703 Arc::new(|| Box::pin(async {})),
1704 UiDialogMailbox::new(),
1705 status.clone(),
1706 crate::editor_text::EditorTextMailbox::new(),
1707 );
1708 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1709
1710 let mut out = StbString::empty();
1711 let rc = trampoline_runtime_action(
1712 18,
1713 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1714 &mut out,
1715 user_data,
1716 );
1717 assert_eq!(rc, 0);
1718 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1719 drop(unsafe {
1721 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1722 });
1723
1724 let mut out = StbString::empty();
1726 assert_eq!(
1727 trampoline_runtime_action(
1728 18,
1729 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1730 &mut out,
1731 user_data
1732 ),
1733 0
1734 );
1735 assert_eq!(status.text(), "");
1736 drop(unsafe {
1738 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1739 });
1740
1741 let mut out = StbString::empty();
1743 assert_eq!(
1744 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1745 1
1746 );
1747 drop(unsafe {
1749 rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(crate::host_free_string))
1750 });
1751 }
1752}
1753
1754impl Drop for ActionBridge {
1755 fn drop(&mut self) {
1756 if let Some((owner, _)) = &self.plugin_identity {
1757 self.ext_status.remove_owner(*owner);
1758 }
1759 }
1760}