1use std::ffi::c_void;
38use std::collections::{HashMap, VecDeque};
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}
70
71#[derive(Clone)]
75pub struct UiDialogMailbox {
76 state: Arc<Mutex<UiDialogState>>,
77}
78
79impl Default for UiDialogMailbox {
80 fn default() -> Self {
81 Self::new()
82 }
83}
84
85impl UiDialogMailbox {
86 pub fn new() -> Self {
87 Self {
88 state: Arc::new(Mutex::new(UiDialogState {
89 attached: false,
90 pending: VecDeque::new(),
91 active: HashMap::new(),
92 })),
93 }
94 }
95
96 pub fn attach(&self) {
98 self.state.lock().expect("ui dialog mailbox poisoned").attached = true;
99 }
100
101 pub fn detach(&self) {
104 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
105 state.attached = false;
106 state.pending.clear();
107 for entry in state.active.values_mut() {
108 entry.cancelled = true;
109 entry.answer = None;
110 }
111 }
112
113 pub fn is_attached(&self) -> bool {
114 self.state
115 .lock()
116 .expect("ui dialog mailbox poisoned")
117 .attached
118 }
119
120 pub fn take_pending(&self) -> Option<UiDialogRequest> {
124 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
125 while let Some(request_id) = state.pending.pop_front() {
126 if let Some(entry) = state.active.get(&request_id) {
127 if !entry.cancelled && entry.answer.is_none() {
128 return Some(entry.request.clone());
129 }
130 }
131 }
132 None
133 }
134
135 pub fn pending_len(&self) -> usize {
136 self.state
137 .lock()
138 .expect("ui dialog mailbox poisoned")
139 .pending
140 .len()
141 }
142
143 pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
146 let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
147 let entry = state
148 .active
149 .get_mut(request_id)
150 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
151 if entry.cancelled {
152 return Err(format!("UI request `{request_id}` was cancelled"));
153 }
154 if entry.answer.is_none() {
155 entry.answer = Some(answer);
156 }
157 Ok(())
158 }
159
160 pub fn cancel(&self, request_id: &str) -> Result<(), String> {
163 let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
164 let entry = state
165 .active
166 .get_mut(request_id)
167 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
168 entry.cancelled = true;
169 entry.answer = None;
170 state.pending.retain(|id| id != request_id);
171 Ok(())
172 }
173
174 pub fn cancel_all(&self) {
176 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
177 state.pending.clear();
178 for entry in state.active.values_mut() {
179 entry.cancelled = true;
180 entry.answer = None;
181 }
182 }
183
184 pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
187 let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
188 let terminal = match state.active.get(request_id) {
189 Some(entry) if entry.cancelled => Some(serde_json::json!({
190 "status": "cancelled",
191 "requestId": request_id,
192 "toolCallId": entry.request.tool_call_id,
193 })),
194 Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
195 "status": "answered",
196 "requestId": request_id,
197 "toolCallId": entry.request.tool_call_id,
198 "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
199 })),
200 Some(entry) => Some(serde_json::json!({
201 "status": "pending",
202 "requestId": request_id,
203 "toolCallId": entry.request.tool_call_id,
204 })),
205 None => None,
206 };
207 let Some(result) = terminal else {
208 return Err(format!("unknown UI request `{request_id}`"));
209 };
210 if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
211 state.active.remove(request_id);
212 }
213 Ok(result)
214 }
215
216 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
220 let op = args.get("op").and_then(serde_json::Value::as_str).unwrap_or("open");
221 let request_id = || {
222 args.get("requestId")
223 .or_else(|| args.get("request_id"))
224 .and_then(serde_json::Value::as_str)
225 .map(str::to_owned)
226 .filter(|id| !id.trim().is_empty())
227 .ok_or_else(|| "missing non-empty `requestId`".to_string())
228 };
229 match op {
230 "open" => {
231 let id = request_id()?;
232 let mut state = self.state.lock().map_err(|_| "ui dialog mailbox poisoned".to_string())?;
233 if !state.attached {
234 return Err("ask_user requires an interactive UI".to_string());
235 }
236 if let Some(entry) = state.active.get(&id) {
237 return Ok(if entry.cancelled {
238 serde_json::json!({"status":"cancelled","requestId":id})
239 } else if let Some(answer) = &entry.answer {
240 serde_json::json!({"status":"answered","requestId":id,"answer":answer})
241 } else {
242 serde_json::json!({"status":"pending","requestId":id})
243 });
244 }
245 let tool_call_id = args
246 .get("toolCallId")
247 .or_else(|| args.get("tool_call_id"))
248 .and_then(serde_json::Value::as_str)
249 .map(str::to_owned);
250 let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
251 state.active.insert(
252 id.clone(),
253 UiDialogEntry {
254 request: UiDialogRequest {
255 request_id: id.clone(),
256 tool_call_id: tool_call_id.clone(),
257 ui,
258 raw: args,
259 },
260 answer: None,
261 cancelled: false,
262 },
263 );
264 state.pending.push_back(id.clone());
265 Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
266 }
267 "poll" => self.poll(&request_id()?),
268 "cancel" => {
269 let id = request_id()?;
270 self.cancel(&id)?;
271 Ok(serde_json::json!({"status":"cancelled","requestId":id}))
272 }
273 "response" | "respond" => {
274 let id = request_id()?;
275 let answer = args
276 .get("answer")
277 .or_else(|| args.get("value"))
278 .cloned()
279 .unwrap_or(serde_json::Value::Null);
280 self.respond(&id, answer)?;
281 Ok(serde_json::json!({"status":"answered","requestId":id}))
282 }
283 other => Err(format!("unknown UI dialog operation `{other}`")),
284 }
285 }
286}
287
288#[async_trait::async_trait]
303pub trait RuntimeActionHost: Send + Sync {
304 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
306 async fn send_user_message(&self, args: serde_json::Value)
308 -> Result<serde_json::Value, String>;
309 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
311 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
313 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
315 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
317 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
319 async fn get_thinking_level(
321 &self,
322 args: serde_json::Value,
323 ) -> Result<serde_json::Value, String>;
324 async fn set_thinking_level(
326 &self,
327 args: serde_json::Value,
328 ) -> Result<serde_json::Value, String>;
329 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
331 async fn get_system_prompt(&self, args: serde_json::Value)
333 -> Result<serde_json::Value, String>;
334 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
336 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
338 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
340 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
342 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
346
347 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
350 Err("CLI flag lookup is not configured".to_string())
351 }
352
353 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
357 Err("ask_user requires an interactive UI".to_string())
358 }
359}
360
361pub struct ActionBridge {
390 pub(crate) runtime: Handle,
392 pub(crate) host: Arc<dyn RuntimeActionHost>,
394 pub(crate) reload:
396 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
397 ui_dialog: UiDialogMailbox,
400 ext_status: crate::status::ExtensionStatusMailbox,
404 active: Arc<AtomicBool>,
407}
408
409impl ActionBridge {
410 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
413 Arc::new(Self {
414 runtime,
415 host,
416 reload: None,
417 ui_dialog: UiDialogMailbox::new(),
418 ext_status: crate::status::ExtensionStatusMailbox::new(),
419 active: Arc::new(AtomicBool::new(true)),
420 })
421 }
422
423 pub fn with_reload(
426 runtime: Handle,
427 host: Arc<dyn RuntimeActionHost>,
428 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
429 ) -> Arc<Self> {
430 Self::with_reload_and_ui(
431 runtime,
432 host,
433 reload,
434 UiDialogMailbox::new(),
435 crate::status::ExtensionStatusMailbox::new(),
436 )
437 }
438
439 pub fn with_ui_dialog(
441 runtime: Handle,
442 host: Arc<dyn RuntimeActionHost>,
443 ui_dialog: UiDialogMailbox,
444 ) -> Arc<Self> {
445 Arc::new(Self {
446 runtime,
447 host,
448 reload: None,
449 ui_dialog,
450 ext_status: crate::status::ExtensionStatusMailbox::new(),
451 active: Arc::new(AtomicBool::new(true)),
452 })
453 }
454
455 pub fn with_reload_and_ui(
457 runtime: Handle,
458 host: Arc<dyn RuntimeActionHost>,
459 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
460 ui_dialog: UiDialogMailbox,
461 ext_status: crate::status::ExtensionStatusMailbox,
462 ) -> Arc<Self> {
463 Arc::new(Self {
464 runtime,
465 host,
466 reload: Some(reload),
467 ui_dialog,
468 ext_status,
469 active: Arc::new(AtomicBool::new(true)),
470 })
471 }
472
473 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
475 self.ui_dialog.clone()
476 }
477
478 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
480 self.ext_status.clone()
481 }
482
483 pub fn invalidate(&self) {
488 self.active.store(false, Ordering::SeqCst);
489 }
490
491 pub fn is_active(&self) -> bool {
493 self.active.load(Ordering::SeqCst)
494 }
495
496 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
503 Arc::clone(&self.host)
504 }
505}
506
507#[derive(Clone)]
520pub struct ReloadMailbox {
521 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
522}
523
524impl Default for ReloadMailbox {
525 fn default() -> Self {
526 Self {
527 tx: Arc::new(std::sync::Mutex::new(None)),
528 }
529 }
530}
531
532impl ReloadMailbox {
533 pub fn new() -> Self {
535 Self::default()
536 }
537
538 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
542 *self.tx.lock().unwrap() = Some(tx);
543 }
544
545 pub fn signal(&self) -> Result<(), ()> {
550 let g = self.tx.lock().unwrap();
551 match &*g {
552 Some(tx) => {
553 let _ = tx.send(());
554 Ok(())
555 }
556 None => Err(()),
557 }
558 }
559
560 pub fn clear(&self) {
562 *self.tx.lock().unwrap() = None;
563 }
564}
565
566pub fn reload_callback_from_mailbox(
573 mailbox: ReloadMailbox,
574) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
575 Arc::new(move || {
576 let m = mailbox.clone();
577 Box::pin(async move {
578 let _ = m.signal();
579 })
580 })
581}
582
583async fn dispatch(
594 host: &Arc<dyn RuntimeActionHost>,
595 ui_dialog: &UiDialogMailbox,
596 ext_status: &crate::status::ExtensionStatusMailbox,
597 action: RuntimeActionId,
598 args: serde_json::Value,
599) -> Result<serde_json::Value, String> {
600 match action {
601 RuntimeActionId::SendMessage => host.send_message(args).await,
602 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
603 RuntimeActionId::AppendEntry => host.append_entry(args).await,
604 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
605 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
606 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
607 RuntimeActionId::SetModel => host.set_model(args).await,
608 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
609 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
610 RuntimeActionId::Compact => host.compact(args).await,
611 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
612 RuntimeActionId::NewSession => host.new_session(args).await,
613 RuntimeActionId::Fork => host.fork(args).await,
614 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
615 RuntimeActionId::SwitchSession => host.switch_session(args).await,
616 RuntimeActionId::Reload => host.reload(args).await,
617 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
618 RuntimeActionId::UiDialog => ui_dialog.handle(args),
619 RuntimeActionId::SetStatus => ext_status.handle(args),
622 }
623}
624
625pub extern "C" fn trampoline_runtime_action(
648 action_id: u32,
649 args_json: StbStringRef,
650 out: *mut StbString,
651 user_data: *mut c_void,
652) -> i32 {
653 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
654 run_action(
655 action_id,
656 u32::from(RuntimeActionId::SetStatus),
658 args_json,
659 out,
660 user_data,
661 )
662 }));
663 match outcome {
664 Ok(rc) => rc,
665 Err(_) => {
666 tracing::error!("runtime_action trampoline panicked — reporting host error");
670 if !out.is_null() {
671 unsafe {
672 *out = StbString::from_string(
673 r#"{"error":"runtime_action panicked"}"#.to_string(),
674 );
675 }
676 }
677 1
678 }
679 }
680}
681
682fn run_action(
684 action_id: u32,
685 max_action_id: u32,
686 args_json: StbStringRef,
687 out: *mut StbString,
688 user_data: *mut c_void,
689) -> i32 {
690 let action = match RuntimeActionId::try_from(action_id) {
694 Ok(action) if action_id <= max_action_id => action,
695 Ok(_) | Err(_) => {
696 if !out.is_null() {
697 let json = serde_json::json!({
698 "error": format!("unknown runtime action id {action_id}")
699 })
700 .to_string();
701 unsafe {
705 *out = StbString::from_string(json);
706 }
707 }
708 return 2;
709 }
710 };
711
712 if user_data.is_null() {
713 return -1;
714 }
715 let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
720
721 if !bridge.is_active() {
729 if out.is_null() {
730 return 1;
731 }
732 let json = serde_json::json!({
733 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
734 })
735 .to_string();
736 unsafe {
737 *out = StbString::from_string(json);
738 }
739 return 1;
740 }
741
742 let args_str = unsafe { args_json.as_str() };
745 let args: serde_json::Value = if args_str.is_empty() {
746 serde_json::Value::Object(serde_json::Map::new())
747 } else {
748 serde_json::from_str(args_str)
749 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
750 };
751
752 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
757 let host = Arc::clone(&bridge.host);
761 let ui_dialog = bridge.ui_dialog.clone();
762 let ext_status = bridge.ext_status.clone();
763 let reload_cb = bridge.reload.clone();
764 if action == RuntimeActionId::SetStatus {
765 std::thread::spawn(move || {
770 let _ = tx.send(ext_status.handle(args));
771 });
772 } else if action == RuntimeActionId::UiDialog {
773 std::thread::spawn(move || {
779 let _ = tx.send(ui_dialog.handle(args));
780 });
781 } else {
782 bridge.runtime.spawn(async move {
783 let r = if action == RuntimeActionId::Reload {
784 if let Some(cb) = reload_cb {
785 cb().await;
786 Ok(serde_json::Value::Null)
787 } else {
788 host.reload(args).await
789 }
790 } else {
791 dispatch(&host, &ui_dialog, &ext_status, action, args).await
792 };
793 let _ = tx.send(r);
796 });
797 }
798
799 let result = match rx.recv() {
800 Ok(r) => r,
801 Err(_) => {
802 return -2;
807 }
808 };
809
810 if out.is_null() {
813 return match result {
815 Ok(_) => 0,
816 Err(_) => 1,
817 };
818 }
819
820 let (rc, payload) = match result {
821 Ok(value) => {
822 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
823 (0, json)
824 }
825 Err(msg) => {
826 let json = serde_json::json!({ "error": msg }).to_string();
827 (1, json)
828 }
829 };
830 unsafe {
836 *out = StbString::from_string(payload);
837 }
838 rc
839}
840
841#[cfg(test)]
850mod tests {
851 use super::*;
852 use std::sync::Mutex;
853
854 struct MockHost {
859 prompt: String,
860 saw: Mutex<Vec<RuntimeActionId>>,
861 }
862
863 #[async_trait::async_trait]
864 impl RuntimeActionHost for MockHost {
865 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
866 unreachable!("not under test")
867 }
868 async fn send_user_message(
869 &self,
870 _: serde_json::Value,
871 ) -> Result<serde_json::Value, String> {
872 unreachable!("not under test")
873 }
874 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
875 unreachable!("not under test")
876 }
877 async fn set_session_name(
878 &self,
879 _: serde_json::Value,
880 ) -> Result<serde_json::Value, String> {
881 unreachable!("not under test")
882 }
883 async fn get_active_tools(
884 &self,
885 _: serde_json::Value,
886 ) -> Result<serde_json::Value, String> {
887 unreachable!("not under test")
888 }
889 async fn set_active_tools(
890 &self,
891 _: serde_json::Value,
892 ) -> Result<serde_json::Value, String> {
893 unreachable!("not under test")
894 }
895 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
896 unreachable!("not under test")
897 }
898 async fn get_thinking_level(
899 &self,
900 _: serde_json::Value,
901 ) -> Result<serde_json::Value, String> {
902 unreachable!("not under test")
903 }
904 async fn set_thinking_level(
905 &self,
906 _: serde_json::Value,
907 ) -> Result<serde_json::Value, String> {
908 unreachable!("not under test")
909 }
910 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
911 unreachable!("not under test")
912 }
913 async fn get_system_prompt(
914 &self,
915 _: serde_json::Value,
916 ) -> Result<serde_json::Value, String> {
917 self.saw
918 .lock()
919 .unwrap()
920 .push(RuntimeActionId::GetSystemPrompt);
921 Ok(serde_json::json!({ "prompt": self.prompt }))
922 }
923 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
924 unreachable!("not under test")
925 }
926 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
927 unreachable!("not under test")
928 }
929 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
930 unreachable!("not under test")
931 }
932 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
933 unreachable!("not under test")
934 }
935 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
936 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
937 Ok(serde_json::Value::Null)
938 }
939 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
940 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
941 Ok(serde_json::json!({ "value": true }))
942 }
943 }
944
945 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
954 async fn trampoline_round_trips_get_system_prompt() {
955 let host = Arc::new(MockHost {
956 prompt: "hello from host".to_string(),
957 saw: Mutex::new(Vec::new()),
958 });
959 let host_for_assert = Arc::clone(&host);
960 let host_dyn: Arc<dyn RuntimeActionHost> = host;
961 let runtime = tokio::runtime::Handle::current();
962 let bridge = ActionBridge::new(runtime, host_dyn);
963 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
964
965 let args_str = "{}";
967 let args_ref = StbStringRef::from_str(args_str);
968
969 let mut out = StbString::empty();
970 let rc = trampoline_runtime_action(
971 RuntimeActionId::GetSystemPrompt.into(),
972 args_ref,
973 &mut out as *mut StbString,
974 user_data,
975 );
976 assert_eq!(rc, 0, "success return code");
977
978 let json_text = out.to_string_lossy();
980 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
981 assert_eq!(parsed["prompt"], "hello from host");
982 crate::host_free_string(out);
983
984 let saw = host_for_assert.saw.lock().unwrap().clone();
987 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
988 }
989
990 #[tokio::test]
991 async fn trampoline_null_user_data_returns_minus_one() {
992 let args_ref = StbStringRef::from_str("{}");
993 let mut out = StbString::empty();
994 let rc = trampoline_runtime_action(
995 RuntimeActionId::GetSystemPrompt.into(),
996 args_ref,
997 &mut out as *mut StbString,
998 std::ptr::null_mut(),
999 );
1000 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1001 }
1002
1003 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1004 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1005 use std::sync::atomic::{AtomicUsize, Ordering};
1009 let reload_calls = Arc::new(AtomicUsize::new(0));
1010 let reload_calls_for_cb = Arc::clone(&reload_calls);
1011 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1012 Arc::new(move || {
1013 let c = Arc::clone(&reload_calls_for_cb);
1014 Box::pin(async move {
1015 c.fetch_add(1, Ordering::SeqCst);
1016 })
1017 });
1018 let host = Arc::new(MockHost {
1019 prompt: String::new(),
1020 saw: Mutex::new(Vec::new()),
1021 });
1022 let host_for_assert = Arc::clone(&host);
1023 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1024 let runtime = tokio::runtime::Handle::current();
1025 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1026 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1027
1028 let args_ref = StbStringRef::from_str("{}");
1029 let mut out = StbString::empty();
1030 let rc = trampoline_runtime_action(
1031 RuntimeActionId::Reload.into(),
1032 args_ref,
1033 &mut out as *mut StbString,
1034 user_data,
1035 );
1036 assert_eq!(rc, 0);
1037 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1039 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1040 crate::host_free_string(out);
1041 }
1042
1043 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1048 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1049 let host = Arc::new(MockHost {
1050 prompt: String::new(),
1051 saw: Mutex::new(Vec::new()),
1052 });
1053 let host_for_assert = Arc::clone(&host);
1054 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1055 let runtime = tokio::runtime::Handle::current();
1056 let bridge = ActionBridge::new(runtime, host_dyn);
1057 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1058
1059 bridge.invalidate();
1061 assert!(!bridge.is_active());
1062
1063 let args_ref = StbStringRef::from_str("{}");
1064 let mut out = StbString::empty();
1065 let rc = trampoline_runtime_action(
1066 RuntimeActionId::GetSystemPrompt.into(),
1067 args_ref,
1068 &mut out as *mut StbString,
1069 user_data,
1070 );
1071 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1073 let json_text = out.to_string_lossy();
1074 assert!(
1075 json_text.contains("stale"),
1076 "stale-bridge error payload: {json_text}"
1077 );
1078 crate::host_free_string(out);
1079 assert!(
1080 host_for_assert.saw.lock().unwrap().is_empty(),
1081 "host dispatch must NOT run on a stale bridge"
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1087 let host = Arc::new(MockHost {
1088 prompt: String::new(),
1089 saw: Mutex::new(Vec::new()),
1090 });
1091 let host_for_assert = Arc::clone(&host);
1092 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1093 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1094 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1095
1096 let mut out = StbString::empty();
1097 let rc = trampoline_runtime_action(
1098 0xFFFF_FFFE,
1099 StbStringRef::from_str("{}"),
1100 &mut out,
1101 user_data,
1102 );
1103
1104 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1105 let payload: serde_json::Value =
1106 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1107 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1108 crate::host_free_string(out);
1109 assert!(
1110 host_for_assert.saw.lock().unwrap().is_empty(),
1111 "unknown action id must not reach host dispatch"
1112 );
1113 }
1114
1115 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1116 async fn v2_trampoline_accepts_new_action() {
1117 let host = Arc::new(MockHost {
1118 prompt: "v2".to_string(),
1119 saw: Mutex::new(Vec::new()),
1120 });
1121 let host_for_assert = Arc::clone(&host);
1122 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1123 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1124 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1125
1126 let mut v2_out = StbString::empty();
1127 assert_eq!(
1128 trampoline_runtime_action(
1129 RuntimeActionId::GetCliFlag.into(),
1130 StbStringRef::from_str(r#"{"name":"flag"}"#),
1131 &mut v2_out,
1132 user_data,
1133 ),
1134 0
1135 );
1136 crate::host_free_string(v2_out);
1137
1138 assert_eq!(
1139 *host_for_assert.saw.lock().unwrap(),
1140 vec![RuntimeActionId::GetCliFlag]
1141 );
1142 }
1143
1144 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1149 async fn reload_mailbox_signals_installed_receiver() {
1150 let mailbox = ReloadMailbox::new();
1151 assert!(matches!(mailbox.signal(), Err(())));
1153
1154 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1155 mailbox.install(tx);
1156 let cb = reload_callback_from_mailbox(mailbox.clone());
1157 cb().await;
1158 assert_eq!(
1159 rx.recv().await,
1160 Some(()),
1161 "installed receiver saw the signal"
1162 );
1163 mailbox.clear();
1164 }
1165
1166 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1169 serde_json::json!({
1170 "op": "open",
1171 "requestId": id,
1172 "toolCallId": tool_call,
1173 "ui": {"kind": "selector", "questions": [
1174 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1175 ]},
1176 })
1177 }
1178
1179 #[test]
1182 fn ui_dialog_open_requires_an_attached_ui() {
1183 let mailbox = UiDialogMailbox::new();
1184 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1185 assert!(error.contains("interactive UI"), "got: {error}");
1186 }
1187
1188 #[test]
1189 fn ui_dialog_open_poll_answer_round_trip() {
1190 let mailbox = UiDialogMailbox::new();
1191 mailbox.attach();
1192
1193 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1194 assert_eq!(opened["status"], "pending");
1195 assert_eq!(opened["toolCallId"], "c1");
1196
1197 let request = mailbox.take_pending().expect("request visible to TUI");
1198 assert_eq!(request.request_id, "r1");
1199 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1200 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1202 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1203
1204 mailbox
1205 .respond("r1", serde_json::json!("7890"))
1206 .expect("respond");
1207 let answered = mailbox.poll("r1").unwrap();
1208 assert_eq!(answered["status"], "answered");
1209 assert_eq!(answered["answer"], "7890");
1210 assert!(mailbox.poll("r1").is_err());
1212 }
1213
1214 #[test]
1215 fn ui_dialog_cancel_is_reported_once() {
1216 let mailbox = UiDialogMailbox::new();
1217 mailbox.attach();
1218 mailbox.handle(open_args("r1", "c1")).unwrap();
1219 mailbox.cancel("r1").unwrap();
1220 let cancelled = mailbox.poll("r1").unwrap();
1221 assert_eq!(cancelled["status"], "cancelled");
1222 assert!(mailbox.poll("r1").is_err());
1223 }
1224
1225 #[test]
1226 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1227 let mailbox = UiDialogMailbox::new();
1228 mailbox.attach();
1229 mailbox.handle(open_args("r1", "c1")).unwrap();
1230 mailbox.handle(open_args("r2", "c2")).unwrap();
1231
1232 let first = mailbox.take_pending().unwrap().request_id;
1233 let second = mailbox.take_pending().unwrap().request_id;
1234 assert_ne!(first, second);
1235
1236 mailbox.respond(&second, serde_json::json!("second")).unwrap();
1238 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1239 let second_answer = mailbox.poll(&second).unwrap();
1240 assert_eq!(second_answer["answer"], "second");
1241
1242 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1243 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1244 }
1245
1246 #[test]
1247 fn ui_dialog_detach_cancels_outstanding() {
1248 let mailbox = UiDialogMailbox::new();
1249 mailbox.attach();
1250 mailbox.handle(open_args("r1", "c1")).unwrap();
1251 mailbox.detach();
1252 assert!(!mailbox.is_attached());
1253 assert_eq!(mailbox.pending_len(), 0);
1254 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1256 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1257 }
1258
1259 #[test]
1260 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1261 let mailbox = UiDialogMailbox::new();
1262 mailbox.attach();
1263 mailbox.handle(open_args("r1", "c1")).unwrap();
1264 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1265 assert_eq!(again["status"], "pending");
1266 assert_eq!(mailbox.pending_len(), 1);
1267 mailbox.take_pending().unwrap();
1268 assert!(mailbox.take_pending().is_none());
1269 }
1270
1271 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1275 async fn v2_dispatch_routes_ui_dialog_action_17() {
1276 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1277 prompt: String::new(),
1278 saw: Mutex::new(Vec::new()),
1279 });
1280 let bridge = ActionBridge::with_ui_dialog(
1281 tokio::runtime::Handle::current(),
1282 host,
1283 UiDialogMailbox::new(),
1284 );
1285 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1286
1287 let mut out = StbString::empty();
1288 let rc = trampoline_runtime_action(
1289 17,
1290 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1291 &mut out,
1292 user_data,
1293 );
1294 assert_eq!(rc, 1);
1295 let payload: serde_json::Value =
1296 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1297 assert!(
1298 payload["error"]
1299 .as_str()
1300 .is_some_and(|error| error.contains("interactive UI")),
1301 "unexpected payload: {payload}"
1302 );
1303 crate::host_free_string(out);
1304
1305 let mut out = StbString::empty();
1307 assert_eq!(
1308 trampoline_runtime_action(19, StbStringRef::from_str("{}"), &mut out, user_data),
1309 2
1310 );
1311 crate::host_free_string(out);
1312 }
1313
1314 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1317 async fn v2_dispatch_routes_set_status_action_18() {
1318 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1319 prompt: String::new(),
1320 saw: Mutex::new(Vec::new()),
1321 });
1322 let status = crate::status::ExtensionStatusMailbox::new();
1323 let bridge = ActionBridge::with_reload_and_ui(
1324 tokio::runtime::Handle::current(),
1325 host,
1326 Arc::new(|| Box::pin(async {})),
1327 UiDialogMailbox::new(),
1328 status.clone(),
1329 );
1330 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1331
1332 let mut out = StbString::empty();
1333 let rc = trampoline_runtime_action(
1334 18,
1335 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1336 &mut out,
1337 user_data,
1338 );
1339 assert_eq!(rc, 0);
1340 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1341 crate::host_free_string(out);
1342
1343 let mut out = StbString::empty();
1345 assert_eq!(
1346 trampoline_runtime_action(
1347 18,
1348 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1349 &mut out,
1350 user_data
1351 ),
1352 0
1353 );
1354 assert_eq!(status.text(), "");
1355 crate::host_free_string(out);
1356
1357 let mut out = StbString::empty();
1359 assert_eq!(
1360 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1361 1
1362 );
1363 crate::host_free_string(out);
1364 }
1365}