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>;
367 async fn send_user_message(&self, args: serde_json::Value)
369 -> Result<serde_json::Value, String>;
370 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
372 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
374 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
376 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
378 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
380 async fn get_thinking_level(
382 &self,
383 args: serde_json::Value,
384 ) -> Result<serde_json::Value, String>;
385 async fn set_thinking_level(
387 &self,
388 args: serde_json::Value,
389 ) -> Result<serde_json::Value, String>;
390 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
392 async fn get_system_prompt(&self, args: serde_json::Value)
394 -> Result<serde_json::Value, String>;
395 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
397 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
399 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
401 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
403 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
407
408 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
411 Err("CLI flag lookup is not configured".to_string())
412 }
413
414 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
418 Err("ask_user requires an interactive UI".to_string())
419 }
420
421 async fn get_session_branch(
423 &self,
424 _args: serde_json::Value,
425 ) -> Result<serde_json::Value, String> {
426 Err("session branch lookup is not configured".to_string())
427 }
428}
429
430pub struct ActionBridge {
459 stdio: crate::StdioLifecycle,
460 plugin_identity: Option<(u64, String)>,
461 component_owners: Arc<std::sync::Mutex<Vec<u64>>>,
462 pub(crate) runtime: Handle,
464 pub(crate) host: Arc<dyn RuntimeActionHost>,
466 pub(crate) reload:
468 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
469 ui_dialog: UiDialogMailbox,
472 ext_status: crate::status::ExtensionStatusMailbox,
476 editor_text: crate::editor_text::EditorTextMailbox,
480 active: Arc<AtomicBool>,
483}
484
485impl ActionBridge {
486 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
489 Arc::new(Self {
490 stdio: crate::StdioLifecycle::default(),
491 plugin_identity: None,
492 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
493 runtime,
494 host,
495 reload: None,
496 ui_dialog: UiDialogMailbox::new(),
497 ext_status: crate::status::ExtensionStatusMailbox::new(),
498 editor_text: crate::editor_text::EditorTextMailbox::new(),
499 active: Arc::new(AtomicBool::new(true)),
500 })
501 }
502
503 pub fn with_reload(
506 runtime: Handle,
507 host: Arc<dyn RuntimeActionHost>,
508 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
509 ) -> Arc<Self> {
510 Self::with_reload_and_ui(
511 runtime,
512 host,
513 reload,
514 UiDialogMailbox::new(),
515 crate::status::ExtensionStatusMailbox::new(),
516 crate::editor_text::EditorTextMailbox::new(),
517 )
518 }
519
520 pub fn with_ui_dialog(
522 runtime: Handle,
523 host: Arc<dyn RuntimeActionHost>,
524 ui_dialog: UiDialogMailbox,
525 ) -> Arc<Self> {
526 Arc::new(Self {
527 stdio: crate::StdioLifecycle::default(),
528 plugin_identity: None,
529 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
530 runtime,
531 host,
532 reload: None,
533 ui_dialog,
534 ext_status: crate::status::ExtensionStatusMailbox::new(),
535 editor_text: crate::editor_text::EditorTextMailbox::new(),
536 active: Arc::new(AtomicBool::new(true)),
537 })
538 }
539
540 pub fn with_reload_and_ui(
542 runtime: Handle,
543 host: Arc<dyn RuntimeActionHost>,
544 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
545 ui_dialog: UiDialogMailbox,
546 ext_status: crate::status::ExtensionStatusMailbox,
547 editor_text: crate::editor_text::EditorTextMailbox,
548 ) -> Arc<Self> {
549 Arc::new(Self {
550 stdio: crate::StdioLifecycle::default(),
551 plugin_identity: None,
552 component_owners: Arc::new(std::sync::Mutex::new(Vec::new())),
553 runtime,
554 host,
555 reload: Some(reload),
556 ui_dialog,
557 ext_status,
558 editor_text,
559 active: Arc::new(AtomicBool::new(true)),
560 })
561 }
562
563 pub(crate) fn for_plugin(&self, name: &str) -> Arc<Self> {
564 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
565 let owner = NEXT_ID.fetch_add(1, Ordering::Relaxed);
566 self.component_owners
567 .lock()
568 .unwrap_or_else(|e| e.into_inner())
569 .push(owner);
570 Arc::new(Self {
571 component_owners: self.component_owners.clone(),
572 stdio: self.stdio.clone(),
573 plugin_identity: Some((owner, name.to_owned())),
574 runtime: self.runtime.clone(),
575 host: self.host.clone(),
576 reload: self.reload.clone(),
577 ui_dialog: self.ui_dialog.clone(),
578 ext_status: self.ext_status.clone(),
579 editor_text: self.editor_text.clone(),
580 active: self.active.clone(),
581 })
582 }
583
584 pub fn stdio_lifecycle(&self) -> crate::StdioLifecycle {
585 self.stdio.clone()
586 }
587
588 pub(crate) fn claim_stdio(&self) -> Result<(), String> {
589 let (id, name) = self
590 .plugin_identity
591 .as_ref()
592 .ok_or("missing plugin identity")?;
593 self.stdio.claim(*id, name)
594 }
595
596 pub(crate) fn release_stdio_claim(&self) {
597 if let Some((id, _)) = &self.plugin_identity {
598 self.stdio.release_failed_registration(*id);
599 self.ext_status.remove_owner(*id);
600 }
601 }
602
603 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
605 self.ui_dialog.clone()
606 }
607
608 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
610 self.ext_status.clone()
611 }
612
613 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
615 self.editor_text.clone()
616 }
617
618 pub fn invalidate(&self) {
623 let owners = self
624 .component_owners
625 .lock()
626 .unwrap_or_else(|e| e.into_inner());
627 self.active.store(false, Ordering::SeqCst);
628 for owner in owners.iter() {
629 self.ext_status.remove_owner(*owner);
630 }
631 }
632
633 pub fn is_active(&self) -> bool {
635 self.active.load(Ordering::SeqCst)
636 }
637
638 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
645 Arc::clone(&self.host)
646 }
647}
648
649#[derive(Clone)]
662pub struct ReloadMailbox {
663 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
664}
665
666impl Default for ReloadMailbox {
667 fn default() -> Self {
668 Self {
669 tx: Arc::new(std::sync::Mutex::new(None)),
670 }
671 }
672}
673
674impl ReloadMailbox {
675 pub fn new() -> Self {
677 Self::default()
678 }
679
680 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
684 *self.tx.lock().unwrap() = Some(tx);
685 }
686
687 pub fn signal(&self) -> Result<(), ()> {
692 let g = self.tx.lock().unwrap();
693 match &*g {
694 Some(tx) => {
695 let _ = tx.send(());
696 Ok(())
697 }
698 None => Err(()),
699 }
700 }
701
702 pub fn clear(&self) {
704 *self.tx.lock().unwrap() = None;
705 }
706}
707
708pub fn reload_callback_from_mailbox(
715 mailbox: ReloadMailbox,
716) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
717 Arc::new(move || {
718 let m = mailbox.clone();
719 Box::pin(async move {
720 let _ = m.signal();
721 })
722 })
723}
724
725async fn dispatch(
736 host: &Arc<dyn RuntimeActionHost>,
737 ui_dialog: &UiDialogMailbox,
738 ext_status: &crate::status::ExtensionStatusMailbox,
739 editor_text: &crate::editor_text::EditorTextMailbox,
740 action: RuntimeActionId,
741 args: serde_json::Value,
742) -> Result<serde_json::Value, String> {
743 match action {
744 RuntimeActionId::SendMessage => host.send_message(args).await,
745 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
746 RuntimeActionId::AppendEntry => host.append_entry(args).await,
747 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
748 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
749 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
750 RuntimeActionId::SetModel => host.set_model(args).await,
751 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
752 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
753 RuntimeActionId::Compact => host.compact(args).await,
754 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
755 RuntimeActionId::NewSession => host.new_session(args).await,
756 RuntimeActionId::Fork => host.fork(args).await,
757 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
758 RuntimeActionId::SwitchSession => host.switch_session(args).await,
759 RuntimeActionId::Reload => host.reload(args).await,
760 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
761 RuntimeActionId::UiDialog => ui_dialog.handle(args),
762 RuntimeActionId::SetStatus => ext_status.handle(args),
765 RuntimeActionId::UiComponent => Err("UiComponent requires a plugin bridge".into()),
766 RuntimeActionId::SetEditorText => editor_text.handle(args),
769 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
770 RuntimeActionId::StdioLifecycle => {
771 Err("stdio lifecycle requires a plugin context".to_owned())
772 }
773 }
774}
775
776pub extern "C" fn trampoline_runtime_action(
799 action_id: u32,
800 args_json: StbStringRef,
801 out: *mut StbString,
802 user_data: *mut c_void,
803) -> i32 {
804 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
805 run_action(
806 action_id,
807 u32::from(RuntimeActionId::UiComponent),
809 args_json,
810 out,
811 user_data,
812 )
813 }));
814 match outcome {
815 Ok(rc) => rc,
816 Err(_) => {
817 tracing::error!("runtime_action trampoline panicked — reporting host error");
821 if !out.is_null() {
822 unsafe {
823 *out = StbString::from_string(
824 r#"{"error":"runtime_action panicked"}"#.to_string(),
825 );
826 }
827 }
828 1
829 }
830 }
831}
832
833fn run_action(
835 action_id: u32,
836 max_action_id: u32,
837 args_json: StbStringRef,
838 out: *mut StbString,
839 user_data: *mut c_void,
840) -> i32 {
841 let action = match RuntimeActionId::try_from(action_id) {
845 Ok(action) if action_id <= max_action_id => action,
846 Ok(_) | Err(_) => {
847 if !out.is_null() {
848 let json = serde_json::json!({
849 "error": format!("unknown runtime action id {action_id}")
850 })
851 .to_string();
852 unsafe {
856 *out = StbString::from_string(json);
857 }
858 }
859 return 2;
860 }
861 };
862
863 if user_data.is_null() {
864 return -1;
865 }
866 let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
871
872 if !bridge.is_active() {
880 if out.is_null() {
881 return 1;
882 }
883 let json = serde_json::json!({
884 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
885 })
886 .to_string();
887 unsafe {
888 *out = StbString::from_string(json);
889 }
890 return 1;
891 }
892
893 let args_str = unsafe { args_json.as_str() };
896 let args: serde_json::Value = if args_str.is_empty() {
897 serde_json::Value::Object(serde_json::Map::new())
898 } else {
899 serde_json::from_str(args_str)
900 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
901 };
902
903 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
908 let host = Arc::clone(&bridge.host);
912 let ui_dialog = bridge.ui_dialog.clone();
913 let ext_status = bridge.ext_status.clone();
914 let editor_text = bridge.editor_text.clone();
915 let reload_cb = bridge.reload.clone();
916 if action == RuntimeActionId::StdioLifecycle {
917 let result = bridge.stdio.handle(
918 bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id),
919 args,
920 );
921 let _ = tx.send(result);
922 } else if action == RuntimeActionId::UiComponent {
923 let owner = bridge.plugin_identity.as_ref().map_or(0, |(id, _)| *id);
924 let _owners = bridge
925 .component_owners
926 .lock()
927 .unwrap_or_else(|e| e.into_inner());
928 let result = if bridge.is_active() {
929 ext_status.component(owner, args)
930 } else {
931 Err("stale plugin bridge".into())
932 };
933 let _ = tx.send(result);
934 } else if action == RuntimeActionId::SetStatus {
935 std::thread::spawn(move || {
940 let _ = tx.send(ext_status.handle(args));
941 });
942 } else if action == RuntimeActionId::SetEditorText {
943 std::thread::spawn(move || {
946 let _ = tx.send(editor_text.handle(args));
947 });
948 } else if action == RuntimeActionId::UiDialog {
949 std::thread::spawn(move || {
955 let _ = tx.send(ui_dialog.handle(args));
956 });
957 } else {
958 bridge.runtime.spawn(async move {
959 let r = if action == RuntimeActionId::Reload {
960 if let Some(cb) = reload_cb {
961 cb().await;
962 Ok(serde_json::Value::Null)
963 } else {
964 host.reload(args).await
965 }
966 } else {
967 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
968 };
969 let _ = tx.send(r);
972 });
973 }
974
975 let result = match rx.recv() {
976 Ok(r) => r,
977 Err(_) => {
978 return -2;
983 }
984 };
985
986 if out.is_null() {
989 return match result {
991 Ok(_) => 0,
992 Err(_) => 1,
993 };
994 }
995
996 let (rc, payload) = match result {
997 Ok(value) => {
998 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
999 (0, json)
1000 }
1001 Err(msg) => {
1002 let json = serde_json::json!({ "error": msg }).to_string();
1003 (1, json)
1004 }
1005 };
1006 unsafe {
1012 *out = StbString::from_string(payload);
1013 }
1014 rc
1015}
1016
1017#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use std::sync::Mutex;
1029
1030 #[tokio::test(flavor = "current_thread")]
1031 async fn stdio_claim_is_exclusive_and_only_its_scoped_context_can_finish() {
1032 let master = ActionBridge::new(
1033 Handle::current(),
1034 Arc::new(MockHost {
1035 prompt: String::new(),
1036 saw: Mutex::new(Vec::new()),
1037 }),
1038 );
1039 let diag: Arc<dyn crate::PluginDiagnostics> = Arc::new(crate::NullDiagnostics);
1040 let owner = crate::HostApi::with_action_bridge(
1041 "owner",
1042 crate::ExtensionRegistry::new(),
1043 diag.clone(),
1044 master.clone(),
1045 );
1046 let other = crate::HostApi::with_action_bridge(
1047 "other",
1048 crate::ExtensionRegistry::new(),
1049 diag,
1050 master.clone(),
1051 );
1052 let vtable = owner.build_vtable();
1053 let claim = r#"{"ownsStdio":true}"#;
1054 unsafe {
1055 crate::set_current_api(&owner);
1056 }
1057 assert_eq!(vtable.declare.unwrap()(StbStringRef::from_str(claim)), 0);
1058 crate::clear_current_api();
1059 let other_vtable = other.build_vtable();
1060 let finish = StbStringRef::from_str(r#"{"op":"finish","exitCode":7}"#);
1061 let mut out = StbString::empty();
1062 assert_eq!(
1064 (other_vtable.runtime_action)(
1065 RuntimeActionId::StdioLifecycle as u32,
1066 finish,
1067 &mut out,
1068 other_vtable.user_data
1069 ),
1070 1
1071 );
1072 (other_vtable.free_string)(out);
1073 assert_eq!(
1074 (vtable.runtime_action)(
1075 RuntimeActionId::StdioLifecycle as u32,
1076 finish,
1077 &mut out,
1078 vtable.user_data
1079 ),
1080 0
1081 );
1082 (vtable.free_string)(out);
1083 assert_eq!(master.stdio_lifecycle().wait().await, 7);
1084 unsafe {
1085 crate::set_current_api(&other);
1086 }
1087 assert_ne!(
1088 other_vtable.declare.unwrap()(StbStringRef::from_str(claim)),
1089 0
1090 );
1091 crate::clear_current_api();
1092 assert!(master
1093 .stdio_lifecycle()
1094 .validate()
1095 .unwrap_err()
1096 .contains("owner"));
1097 }
1098
1099 struct MockHost {
1104 prompt: String,
1105 saw: Mutex<Vec<RuntimeActionId>>,
1106 }
1107
1108 #[async_trait::async_trait]
1109 impl RuntimeActionHost for MockHost {
1110 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1111 unreachable!("not under test")
1112 }
1113 async fn send_user_message(
1114 &self,
1115 _: serde_json::Value,
1116 ) -> Result<serde_json::Value, String> {
1117 unreachable!("not under test")
1118 }
1119 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1120 unreachable!("not under test")
1121 }
1122 async fn set_session_name(
1123 &self,
1124 _: serde_json::Value,
1125 ) -> Result<serde_json::Value, String> {
1126 unreachable!("not under test")
1127 }
1128 async fn get_active_tools(
1129 &self,
1130 _: serde_json::Value,
1131 ) -> Result<serde_json::Value, String> {
1132 unreachable!("not under test")
1133 }
1134 async fn set_active_tools(
1135 &self,
1136 _: serde_json::Value,
1137 ) -> Result<serde_json::Value, String> {
1138 unreachable!("not under test")
1139 }
1140 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1141 unreachable!("not under test")
1142 }
1143 async fn get_thinking_level(
1144 &self,
1145 _: serde_json::Value,
1146 ) -> Result<serde_json::Value, String> {
1147 unreachable!("not under test")
1148 }
1149 async fn set_thinking_level(
1150 &self,
1151 _: serde_json::Value,
1152 ) -> Result<serde_json::Value, String> {
1153 unreachable!("not under test")
1154 }
1155 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1156 unreachable!("not under test")
1157 }
1158 async fn get_system_prompt(
1159 &self,
1160 _: serde_json::Value,
1161 ) -> Result<serde_json::Value, String> {
1162 self.saw
1163 .lock()
1164 .unwrap()
1165 .push(RuntimeActionId::GetSystemPrompt);
1166 Ok(serde_json::json!({ "prompt": self.prompt }))
1167 }
1168 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1169 unreachable!("not under test")
1170 }
1171 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1172 unreachable!("not under test")
1173 }
1174 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1175 unreachable!("not under test")
1176 }
1177 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1178 unreachable!("not under test")
1179 }
1180 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1181 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
1182 Ok(serde_json::Value::Null)
1183 }
1184 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
1185 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
1186 Ok(serde_json::json!({ "value": true }))
1187 }
1188 }
1189
1190 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1199 async fn trampoline_round_trips_get_system_prompt() {
1200 let host = Arc::new(MockHost {
1201 prompt: "hello from host".to_string(),
1202 saw: Mutex::new(Vec::new()),
1203 });
1204 let host_for_assert = Arc::clone(&host);
1205 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1206 let runtime = tokio::runtime::Handle::current();
1207 let bridge = ActionBridge::new(runtime, host_dyn);
1208 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1209
1210 let args_str = "{}";
1212 let args_ref = StbStringRef::from_str(args_str);
1213
1214 let mut out = StbString::empty();
1215 let rc = trampoline_runtime_action(
1216 RuntimeActionId::GetSystemPrompt.into(),
1217 args_ref,
1218 &mut out as *mut StbString,
1219 user_data,
1220 );
1221 assert_eq!(rc, 0, "success return code");
1222
1223 let json_text = out.to_string_lossy();
1225 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1226 assert_eq!(parsed["prompt"], "hello from host");
1227 crate::host_free_string(out);
1228
1229 let saw = host_for_assert.saw.lock().unwrap().clone();
1232 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1233 }
1234
1235 #[tokio::test]
1241 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1242 let host = Arc::new(MockHost {
1243 prompt: String::new(),
1244 saw: Mutex::new(Vec::new()),
1245 });
1246 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1247 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1248 let mailbox = bridge.editor_text_mailbox();
1249 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1250
1251 let args_ref =
1252 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1253 let mut out = StbString::empty();
1254 let rc = trampoline_runtime_action(
1255 RuntimeActionId::SetEditorText.into(),
1256 args_ref,
1257 &mut out as *mut StbString,
1258 user_data,
1259 );
1260 assert_eq!(rc, 0, "success return code");
1261
1262 let parsed: serde_json::Value =
1263 serde_json::from_str(&out.to_string_lossy()).expect("valid json");
1264 assert_eq!(parsed["ok"], serde_json::json!(true));
1265 assert_eq!(parsed["chars"], serde_json::json!(10));
1266 crate::host_free_string(out);
1267
1268 let edit = mailbox.take_pending().expect("queued edit");
1270 assert_eq!(edit.text, "from voice");
1271 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1272 assert_eq!(edit.auto_send_ms, Some(2000));
1273 assert!(mailbox.take_pending().is_none());
1274 }
1275
1276 #[tokio::test]
1277 async fn trampoline_null_user_data_returns_minus_one() {
1278 let args_ref = StbStringRef::from_str("{}");
1279 let mut out = StbString::empty();
1280 let rc = trampoline_runtime_action(
1281 RuntimeActionId::GetSystemPrompt.into(),
1282 args_ref,
1283 &mut out as *mut StbString,
1284 std::ptr::null_mut(),
1285 );
1286 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1287 }
1288
1289 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1290 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1291 use std::sync::atomic::{AtomicUsize, Ordering};
1295 let reload_calls = Arc::new(AtomicUsize::new(0));
1296 let reload_calls_for_cb = Arc::clone(&reload_calls);
1297 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1298 Arc::new(move || {
1299 let c = Arc::clone(&reload_calls_for_cb);
1300 Box::pin(async move {
1301 c.fetch_add(1, Ordering::SeqCst);
1302 })
1303 });
1304 let host = Arc::new(MockHost {
1305 prompt: String::new(),
1306 saw: Mutex::new(Vec::new()),
1307 });
1308 let host_for_assert = Arc::clone(&host);
1309 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1310 let runtime = tokio::runtime::Handle::current();
1311 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1312 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1313
1314 let args_ref = StbStringRef::from_str("{}");
1315 let mut out = StbString::empty();
1316 let rc = trampoline_runtime_action(
1317 RuntimeActionId::Reload.into(),
1318 args_ref,
1319 &mut out as *mut StbString,
1320 user_data,
1321 );
1322 assert_eq!(rc, 0);
1323 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1325 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1326 crate::host_free_string(out);
1327 }
1328
1329 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1334 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1335 let host = Arc::new(MockHost {
1336 prompt: String::new(),
1337 saw: Mutex::new(Vec::new()),
1338 });
1339 let host_for_assert = Arc::clone(&host);
1340 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1341 let runtime = tokio::runtime::Handle::current();
1342 let bridge = ActionBridge::new(runtime, host_dyn);
1343 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1344
1345 bridge.invalidate();
1347 assert!(!bridge.is_active());
1348
1349 let args_ref = StbStringRef::from_str("{}");
1350 let mut out = StbString::empty();
1351 let rc = trampoline_runtime_action(
1352 RuntimeActionId::GetSystemPrompt.into(),
1353 args_ref,
1354 &mut out as *mut StbString,
1355 user_data,
1356 );
1357 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1359 let json_text = out.to_string_lossy();
1360 assert!(
1361 json_text.contains("stale"),
1362 "stale-bridge error payload: {json_text}"
1363 );
1364 crate::host_free_string(out);
1365 assert!(
1366 host_for_assert.saw.lock().unwrap().is_empty(),
1367 "host dispatch must NOT run on a stale bridge"
1368 );
1369 }
1370
1371 #[tokio::test]
1372 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1373 let host = Arc::new(MockHost {
1374 prompt: String::new(),
1375 saw: Mutex::new(Vec::new()),
1376 });
1377 let host_for_assert = Arc::clone(&host);
1378 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1379 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1380 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1381
1382 let mut out = StbString::empty();
1383 let rc = trampoline_runtime_action(
1384 0xFFFF_FFFE,
1385 StbStringRef::from_str("{}"),
1386 &mut out,
1387 user_data,
1388 );
1389
1390 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1391 let payload: serde_json::Value =
1392 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1393 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1394 crate::host_free_string(out);
1395 assert!(
1396 host_for_assert.saw.lock().unwrap().is_empty(),
1397 "unknown action id must not reach host dispatch"
1398 );
1399 }
1400
1401 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1402 async fn v2_trampoline_accepts_new_action() {
1403 let host = Arc::new(MockHost {
1404 prompt: "v2".to_string(),
1405 saw: Mutex::new(Vec::new()),
1406 });
1407 let host_for_assert = Arc::clone(&host);
1408 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1409 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1410 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1411
1412 let mut v2_out = StbString::empty();
1413 assert_eq!(
1414 trampoline_runtime_action(
1415 RuntimeActionId::GetCliFlag.into(),
1416 StbStringRef::from_str(r#"{"name":"flag"}"#),
1417 &mut v2_out,
1418 user_data,
1419 ),
1420 0
1421 );
1422 crate::host_free_string(v2_out);
1423
1424 assert_eq!(
1425 *host_for_assert.saw.lock().unwrap(),
1426 vec![RuntimeActionId::GetCliFlag]
1427 );
1428 }
1429
1430 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1435 async fn reload_mailbox_signals_installed_receiver() {
1436 let mailbox = ReloadMailbox::new();
1437 assert!(matches!(mailbox.signal(), Err(())));
1439
1440 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1441 mailbox.install(tx);
1442 let cb = reload_callback_from_mailbox(mailbox.clone());
1443 cb().await;
1444 assert_eq!(
1445 rx.recv().await,
1446 Some(()),
1447 "installed receiver saw the signal"
1448 );
1449 mailbox.clear();
1450 }
1451
1452 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1455 serde_json::json!({
1456 "op": "open",
1457 "requestId": id,
1458 "toolCallId": tool_call,
1459 "ui": {"kind": "selector", "questions": [
1460 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1461 ]},
1462 })
1463 }
1464
1465 #[test]
1468 fn ui_dialog_open_requires_an_attached_ui() {
1469 let mailbox = UiDialogMailbox::new();
1470 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1471 assert!(error.contains("interactive UI"), "got: {error}");
1472 }
1473
1474 #[test]
1475 fn ui_dialog_open_poll_answer_round_trip() {
1476 let mailbox = UiDialogMailbox::new();
1477 mailbox.attach();
1478
1479 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1480 assert_eq!(opened["status"], "pending");
1481 assert_eq!(opened["toolCallId"], "c1");
1482
1483 let request = mailbox.take_pending().expect("request visible to TUI");
1484 assert_eq!(request.request_id, "r1");
1485 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1486 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1488 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1489
1490 mailbox
1491 .respond("r1", serde_json::json!("7890"))
1492 .expect("respond");
1493 let answered = mailbox.poll("r1").unwrap();
1494 assert_eq!(answered["status"], "answered");
1495 assert_eq!(answered["answer"], "7890");
1496 assert!(mailbox.poll("r1").is_err());
1498 }
1499
1500 #[test]
1501 fn ui_dialog_cancel_is_reported_once() {
1502 let mailbox = UiDialogMailbox::new();
1503 mailbox.attach();
1504 mailbox.handle(open_args("r1", "c1")).unwrap();
1505 mailbox.cancel("r1").unwrap();
1506 let cancelled = mailbox.poll("r1").unwrap();
1507 assert_eq!(cancelled["status"], "cancelled");
1508 assert!(mailbox.poll("r1").is_err());
1509 }
1510
1511 #[test]
1512 fn ui_dialog_tool_cancel_preserves_other_calls_and_rejects_late_questions() {
1513 let mailbox = UiDialogMailbox::new();
1514 mailbox.attach();
1515 mailbox.handle(open_args("r1", "c1")).unwrap();
1516 mailbox.handle(open_args("r2", "c2")).unwrap();
1517 mailbox.cancel_tool_call("c1");
1518 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1519 assert_eq!(mailbox.take_pending().unwrap().request_id, "r2");
1520 assert_eq!(mailbox.poll("r2").unwrap()["status"], "pending");
1521 assert_eq!(
1522 mailbox.handle(open_args("late", "c1")).unwrap()["status"],
1523 "cancelled"
1524 );
1525 assert!(mailbox.take_pending().is_none());
1526 }
1527
1528 #[test]
1529 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1530 let mailbox = UiDialogMailbox::new();
1531 mailbox.attach();
1532 mailbox.handle(open_args("r1", "c1")).unwrap();
1533 mailbox.handle(open_args("r2", "c2")).unwrap();
1534
1535 let first = mailbox.take_pending().unwrap().request_id;
1536 let second = mailbox.take_pending().unwrap().request_id;
1537 assert_ne!(first, second);
1538
1539 mailbox
1541 .respond(&second, serde_json::json!("second"))
1542 .unwrap();
1543 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1544 let second_answer = mailbox.poll(&second).unwrap();
1545 assert_eq!(second_answer["answer"], "second");
1546
1547 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1548 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1549 }
1550
1551 #[test]
1552 fn ui_dialog_detach_cancels_outstanding() {
1553 let mailbox = UiDialogMailbox::new();
1554 mailbox.attach();
1555 mailbox.handle(open_args("r1", "c1")).unwrap();
1556 mailbox.detach();
1557 assert!(!mailbox.is_attached());
1558 assert_eq!(mailbox.pending_len(), 0);
1559 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1561 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1562 }
1563
1564 #[test]
1565 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1566 let mailbox = UiDialogMailbox::new();
1567 mailbox.attach();
1568 mailbox.handle(open_args("r1", "c1")).unwrap();
1569 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1570 assert_eq!(again["status"], "pending");
1571 assert_eq!(mailbox.pending_len(), 1);
1572 mailbox.take_pending().unwrap();
1573 assert!(mailbox.take_pending().is_none());
1574 }
1575
1576 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1580 async fn v2_dispatch_routes_ui_dialog_action_17() {
1581 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1582 prompt: String::new(),
1583 saw: Mutex::new(Vec::new()),
1584 });
1585 let bridge = ActionBridge::with_ui_dialog(
1586 tokio::runtime::Handle::current(),
1587 host,
1588 UiDialogMailbox::new(),
1589 );
1590 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1591
1592 let mut out = StbString::empty();
1593 let rc = trampoline_runtime_action(
1594 17,
1595 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1596 &mut out,
1597 user_data,
1598 );
1599 assert_eq!(rc, 1);
1600 let payload: serde_json::Value =
1601 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1602 assert!(
1603 payload["error"]
1604 .as_str()
1605 .is_some_and(|error| error.contains("interactive UI")),
1606 "unexpected payload: {payload}"
1607 );
1608 crate::host_free_string(out);
1609
1610 let mut out = StbString::empty();
1613 assert_eq!(
1614 trampoline_runtime_action(23, StbStringRef::from_str("{}"), &mut out, user_data),
1615 2
1616 );
1617 crate::host_free_string(out);
1618 }
1619
1620 #[tokio::test(flavor = "current_thread")]
1621 async fn component_bridge_isolates_plugins_and_cleans_up_reload_and_drop() {
1622 let host = Arc::new(MockHost {
1623 prompt: String::new(),
1624 saw: Mutex::new(Vec::new()),
1625 });
1626 let root = ActionBridge::new(tokio::runtime::Handle::current(), host);
1627 let a = root.for_plugin("a");
1628 let b = root.for_plugin("b");
1629 let mailbox = root.extension_status_mailbox();
1630 for plugin in [&a, &b] {
1631 let mut out = StbString::empty();
1632 assert_eq!(
1633 trampoline_runtime_action(
1634 22,
1635 StbStringRef::from_str(
1636 r#"{"op":"register","id":"same","slot":"footer.after"}"#
1637 ),
1638 &mut out,
1639 Arc::as_ptr(plugin) as *mut c_void
1640 ),
1641 0
1642 );
1643 crate::host_free_string(out);
1644 }
1645 assert_eq!(mailbox.panels().len(), 2);
1646 drop(a);
1647 assert_eq!(mailbox.panels().len(), 1);
1648 root.invalidate();
1649 assert!(mailbox.panels().is_empty());
1650 }
1651
1652 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1655 async fn v2_dispatch_routes_set_status_action_18() {
1656 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1657 prompt: String::new(),
1658 saw: Mutex::new(Vec::new()),
1659 });
1660 let status = crate::status::ExtensionStatusMailbox::new();
1661 let bridge = ActionBridge::with_reload_and_ui(
1662 tokio::runtime::Handle::current(),
1663 host,
1664 Arc::new(|| Box::pin(async {})),
1665 UiDialogMailbox::new(),
1666 status.clone(),
1667 crate::editor_text::EditorTextMailbox::new(),
1668 );
1669 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1670
1671 let mut out = StbString::empty();
1672 let rc = trampoline_runtime_action(
1673 18,
1674 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1675 &mut out,
1676 user_data,
1677 );
1678 assert_eq!(rc, 0);
1679 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1680 crate::host_free_string(out);
1681
1682 let mut out = StbString::empty();
1684 assert_eq!(
1685 trampoline_runtime_action(
1686 18,
1687 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1688 &mut out,
1689 user_data
1690 ),
1691 0
1692 );
1693 assert_eq!(status.text(), "");
1694 crate::host_free_string(out);
1695
1696 let mut out = StbString::empty();
1698 assert_eq!(
1699 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1700 1
1701 );
1702 crate::host_free_string(out);
1703 }
1704}
1705
1706impl Drop for ActionBridge {
1707 fn drop(&mut self) {
1708 if let Some((owner, _)) = &self.plugin_identity {
1709 self.ext_status.remove_owner(*owner);
1710 }
1711 }
1712}