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}
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
99 .lock()
100 .expect("ui dialog mailbox poisoned")
101 .attached = true;
102 }
103
104 pub fn detach(&self) {
107 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
108 state.attached = false;
109 state.pending.clear();
110 for entry in state.active.values_mut() {
111 entry.cancelled = true;
112 entry.answer = None;
113 }
114 }
115
116 pub fn is_attached(&self) -> bool {
117 self.state
118 .lock()
119 .expect("ui dialog mailbox poisoned")
120 .attached
121 }
122
123 pub fn take_pending(&self) -> Option<UiDialogRequest> {
127 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
128 while let Some(request_id) = state.pending.pop_front() {
129 if let Some(entry) = state.active.get(&request_id) {
130 if !entry.cancelled && entry.answer.is_none() {
131 return Some(entry.request.clone());
132 }
133 }
134 }
135 None
136 }
137
138 pub fn pending_len(&self) -> usize {
139 self.state
140 .lock()
141 .expect("ui dialog mailbox poisoned")
142 .pending
143 .len()
144 }
145
146 pub fn respond(&self, request_id: &str, answer: serde_json::Value) -> Result<(), String> {
149 let mut state = self
150 .state
151 .lock()
152 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
153 let entry = state
154 .active
155 .get_mut(request_id)
156 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
157 if entry.cancelled {
158 return Err(format!("UI request `{request_id}` was cancelled"));
159 }
160 if entry.answer.is_none() {
161 entry.answer = Some(answer);
162 }
163 Ok(())
164 }
165
166 pub fn cancel(&self, request_id: &str) -> Result<(), String> {
169 let mut state = self
170 .state
171 .lock()
172 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
173 let entry = state
174 .active
175 .get_mut(request_id)
176 .ok_or_else(|| format!("unknown UI request `{request_id}`"))?;
177 entry.cancelled = true;
178 entry.answer = None;
179 state.pending.retain(|id| id != request_id);
180 Ok(())
181 }
182
183 pub fn cancel_all(&self) {
185 let mut state = self.state.lock().expect("ui dialog mailbox poisoned");
186 state.pending.clear();
187 for entry in state.active.values_mut() {
188 entry.cancelled = true;
189 entry.answer = None;
190 }
191 }
192
193 pub fn poll(&self, request_id: &str) -> Result<serde_json::Value, String> {
196 let mut state = self
197 .state
198 .lock()
199 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
200 let terminal = match state.active.get(request_id) {
201 Some(entry) if entry.cancelled => Some(serde_json::json!({
202 "status": "cancelled",
203 "requestId": request_id,
204 "toolCallId": entry.request.tool_call_id,
205 })),
206 Some(entry) if entry.answer.is_some() => Some(serde_json::json!({
207 "status": "answered",
208 "requestId": request_id,
209 "toolCallId": entry.request.tool_call_id,
210 "answer": entry.answer.clone().unwrap_or(serde_json::Value::Null),
211 })),
212 Some(entry) => Some(serde_json::json!({
213 "status": "pending",
214 "requestId": request_id,
215 "toolCallId": entry.request.tool_call_id,
216 })),
217 None => None,
218 };
219 let Some(result) = terminal else {
220 return Err(format!("unknown UI request `{request_id}`"));
221 };
222 if result.get("status").and_then(serde_json::Value::as_str) != Some("pending") {
223 state.active.remove(request_id);
224 }
225 Ok(result)
226 }
227
228 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
232 let op = args
233 .get("op")
234 .and_then(serde_json::Value::as_str)
235 .unwrap_or("open");
236 let request_id = || {
237 args.get("requestId")
238 .or_else(|| args.get("request_id"))
239 .and_then(serde_json::Value::as_str)
240 .map(str::to_owned)
241 .filter(|id| !id.trim().is_empty())
242 .ok_or_else(|| "missing non-empty `requestId`".to_string())
243 };
244 match op {
245 "open" => {
246 let id = request_id()?;
247 let mut state = self
248 .state
249 .lock()
250 .map_err(|_| "ui dialog mailbox poisoned".to_string())?;
251 if !state.attached {
252 return Err("ask_user requires an interactive UI".to_string());
253 }
254 if let Some(entry) = state.active.get(&id) {
255 return Ok(if entry.cancelled {
256 serde_json::json!({"status":"cancelled","requestId":id})
257 } else if let Some(answer) = &entry.answer {
258 serde_json::json!({"status":"answered","requestId":id,"answer":answer})
259 } else {
260 serde_json::json!({"status":"pending","requestId":id})
261 });
262 }
263 let tool_call_id = args
264 .get("toolCallId")
265 .or_else(|| args.get("tool_call_id"))
266 .and_then(serde_json::Value::as_str)
267 .map(str::to_owned);
268 let ui = args.get("ui").cloned().unwrap_or_else(|| args.clone());
269 state.active.insert(
270 id.clone(),
271 UiDialogEntry {
272 request: UiDialogRequest {
273 request_id: id.clone(),
274 tool_call_id: tool_call_id.clone(),
275 ui,
276 raw: args,
277 },
278 answer: None,
279 cancelled: false,
280 },
281 );
282 state.pending.push_back(id.clone());
283 Ok(serde_json::json!({"status":"pending","requestId":id,"toolCallId":tool_call_id}))
284 }
285 "poll" => self.poll(&request_id()?),
286 "cancel" => {
287 let id = request_id()?;
288 self.cancel(&id)?;
289 Ok(serde_json::json!({"status":"cancelled","requestId":id}))
290 }
291 "response" | "respond" => {
292 let id = request_id()?;
293 let answer = args
294 .get("answer")
295 .or_else(|| args.get("value"))
296 .cloned()
297 .unwrap_or(serde_json::Value::Null);
298 self.respond(&id, answer)?;
299 Ok(serde_json::json!({"status":"answered","requestId":id}))
300 }
301 other => Err(format!("unknown UI dialog operation `{other}`")),
302 }
303 }
304}
305
306#[async_trait::async_trait]
321pub trait RuntimeActionHost: Send + Sync {
322 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
324 async fn send_user_message(&self, args: serde_json::Value)
326 -> Result<serde_json::Value, String>;
327 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
329 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
331 async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
333 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
335 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
337 async fn get_thinking_level(
339 &self,
340 args: serde_json::Value,
341 ) -> Result<serde_json::Value, String>;
342 async fn set_thinking_level(
344 &self,
345 args: serde_json::Value,
346 ) -> Result<serde_json::Value, String>;
347 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
349 async fn get_system_prompt(&self, args: serde_json::Value)
351 -> Result<serde_json::Value, String>;
352 async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
354 async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
356 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
358 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
360 async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
364
365 async fn get_cli_flag(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
368 Err("CLI flag lookup is not configured".to_string())
369 }
370
371 async fn ui_dialog(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
375 Err("ask_user requires an interactive UI".to_string())
376 }
377}
378
379pub struct ActionBridge {
408 pub(crate) runtime: Handle,
410 pub(crate) host: Arc<dyn RuntimeActionHost>,
412 pub(crate) reload:
414 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
415 ui_dialog: UiDialogMailbox,
418 ext_status: crate::status::ExtensionStatusMailbox,
422 active: Arc<AtomicBool>,
425}
426
427impl ActionBridge {
428 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
431 Arc::new(Self {
432 runtime,
433 host,
434 reload: None,
435 ui_dialog: UiDialogMailbox::new(),
436 ext_status: crate::status::ExtensionStatusMailbox::new(),
437 active: Arc::new(AtomicBool::new(true)),
438 })
439 }
440
441 pub fn with_reload(
444 runtime: Handle,
445 host: Arc<dyn RuntimeActionHost>,
446 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
447 ) -> Arc<Self> {
448 Self::with_reload_and_ui(
449 runtime,
450 host,
451 reload,
452 UiDialogMailbox::new(),
453 crate::status::ExtensionStatusMailbox::new(),
454 )
455 }
456
457 pub fn with_ui_dialog(
459 runtime: Handle,
460 host: Arc<dyn RuntimeActionHost>,
461 ui_dialog: UiDialogMailbox,
462 ) -> Arc<Self> {
463 Arc::new(Self {
464 runtime,
465 host,
466 reload: None,
467 ui_dialog,
468 ext_status: crate::status::ExtensionStatusMailbox::new(),
469 active: Arc::new(AtomicBool::new(true)),
470 })
471 }
472
473 pub fn with_reload_and_ui(
475 runtime: Handle,
476 host: Arc<dyn RuntimeActionHost>,
477 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
478 ui_dialog: UiDialogMailbox,
479 ext_status: crate::status::ExtensionStatusMailbox,
480 ) -> Arc<Self> {
481 Arc::new(Self {
482 runtime,
483 host,
484 reload: Some(reload),
485 ui_dialog,
486 ext_status,
487 active: Arc::new(AtomicBool::new(true)),
488 })
489 }
490
491 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
493 self.ui_dialog.clone()
494 }
495
496 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
498 self.ext_status.clone()
499 }
500
501 pub fn invalidate(&self) {
506 self.active.store(false, Ordering::SeqCst);
507 }
508
509 pub fn is_active(&self) -> bool {
511 self.active.load(Ordering::SeqCst)
512 }
513
514 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
521 Arc::clone(&self.host)
522 }
523}
524
525#[derive(Clone)]
538pub struct ReloadMailbox {
539 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
540}
541
542impl Default for ReloadMailbox {
543 fn default() -> Self {
544 Self {
545 tx: Arc::new(std::sync::Mutex::new(None)),
546 }
547 }
548}
549
550impl ReloadMailbox {
551 pub fn new() -> Self {
553 Self::default()
554 }
555
556 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
560 *self.tx.lock().unwrap() = Some(tx);
561 }
562
563 pub fn signal(&self) -> Result<(), ()> {
568 let g = self.tx.lock().unwrap();
569 match &*g {
570 Some(tx) => {
571 let _ = tx.send(());
572 Ok(())
573 }
574 None => Err(()),
575 }
576 }
577
578 pub fn clear(&self) {
580 *self.tx.lock().unwrap() = None;
581 }
582}
583
584pub fn reload_callback_from_mailbox(
591 mailbox: ReloadMailbox,
592) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
593 Arc::new(move || {
594 let m = mailbox.clone();
595 Box::pin(async move {
596 let _ = m.signal();
597 })
598 })
599}
600
601async fn dispatch(
612 host: &Arc<dyn RuntimeActionHost>,
613 ui_dialog: &UiDialogMailbox,
614 ext_status: &crate::status::ExtensionStatusMailbox,
615 action: RuntimeActionId,
616 args: serde_json::Value,
617) -> Result<serde_json::Value, String> {
618 match action {
619 RuntimeActionId::SendMessage => host.send_message(args).await,
620 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
621 RuntimeActionId::AppendEntry => host.append_entry(args).await,
622 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
623 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
624 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
625 RuntimeActionId::SetModel => host.set_model(args).await,
626 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
627 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
628 RuntimeActionId::Compact => host.compact(args).await,
629 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
630 RuntimeActionId::NewSession => host.new_session(args).await,
631 RuntimeActionId::Fork => host.fork(args).await,
632 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
633 RuntimeActionId::SwitchSession => host.switch_session(args).await,
634 RuntimeActionId::Reload => host.reload(args).await,
635 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
636 RuntimeActionId::UiDialog => ui_dialog.handle(args),
637 RuntimeActionId::SetStatus => ext_status.handle(args),
640 }
641}
642
643pub extern "C" fn trampoline_runtime_action(
666 action_id: u32,
667 args_json: StbStringRef,
668 out: *mut StbString,
669 user_data: *mut c_void,
670) -> i32 {
671 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
672 run_action(
673 action_id,
674 u32::from(RuntimeActionId::SetStatus),
676 args_json,
677 out,
678 user_data,
679 )
680 }));
681 match outcome {
682 Ok(rc) => rc,
683 Err(_) => {
684 tracing::error!("runtime_action trampoline panicked — reporting host error");
688 if !out.is_null() {
689 unsafe {
690 *out = StbString::from_string(
691 r#"{"error":"runtime_action panicked"}"#.to_string(),
692 );
693 }
694 }
695 1
696 }
697 }
698}
699
700fn run_action(
702 action_id: u32,
703 max_action_id: u32,
704 args_json: StbStringRef,
705 out: *mut StbString,
706 user_data: *mut c_void,
707) -> i32 {
708 let action = match RuntimeActionId::try_from(action_id) {
712 Ok(action) if action_id <= max_action_id => action,
713 Ok(_) | Err(_) => {
714 if !out.is_null() {
715 let json = serde_json::json!({
716 "error": format!("unknown runtime action id {action_id}")
717 })
718 .to_string();
719 unsafe {
723 *out = StbString::from_string(json);
724 }
725 }
726 return 2;
727 }
728 };
729
730 if user_data.is_null() {
731 return -1;
732 }
733 let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
738
739 if !bridge.is_active() {
747 if out.is_null() {
748 return 1;
749 }
750 let json = serde_json::json!({
751 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
752 })
753 .to_string();
754 unsafe {
755 *out = StbString::from_string(json);
756 }
757 return 1;
758 }
759
760 let args_str = unsafe { args_json.as_str() };
763 let args: serde_json::Value = if args_str.is_empty() {
764 serde_json::Value::Object(serde_json::Map::new())
765 } else {
766 serde_json::from_str(args_str)
767 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
768 };
769
770 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
775 let host = Arc::clone(&bridge.host);
779 let ui_dialog = bridge.ui_dialog.clone();
780 let ext_status = bridge.ext_status.clone();
781 let reload_cb = bridge.reload.clone();
782 if action == RuntimeActionId::SetStatus {
783 std::thread::spawn(move || {
788 let _ = tx.send(ext_status.handle(args));
789 });
790 } else if action == RuntimeActionId::UiDialog {
791 std::thread::spawn(move || {
797 let _ = tx.send(ui_dialog.handle(args));
798 });
799 } else {
800 bridge.runtime.spawn(async move {
801 let r = if action == RuntimeActionId::Reload {
802 if let Some(cb) = reload_cb {
803 cb().await;
804 Ok(serde_json::Value::Null)
805 } else {
806 host.reload(args).await
807 }
808 } else {
809 dispatch(&host, &ui_dialog, &ext_status, action, args).await
810 };
811 let _ = tx.send(r);
814 });
815 }
816
817 let result = match rx.recv() {
818 Ok(r) => r,
819 Err(_) => {
820 return -2;
825 }
826 };
827
828 if out.is_null() {
831 return match result {
833 Ok(_) => 0,
834 Err(_) => 1,
835 };
836 }
837
838 let (rc, payload) = match result {
839 Ok(value) => {
840 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
841 (0, json)
842 }
843 Err(msg) => {
844 let json = serde_json::json!({ "error": msg }).to_string();
845 (1, json)
846 }
847 };
848 unsafe {
854 *out = StbString::from_string(payload);
855 }
856 rc
857}
858
859#[cfg(test)]
868mod tests {
869 use super::*;
870 use std::sync::Mutex;
871
872 struct MockHost {
877 prompt: String,
878 saw: Mutex<Vec<RuntimeActionId>>,
879 }
880
881 #[async_trait::async_trait]
882 impl RuntimeActionHost for MockHost {
883 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
884 unreachable!("not under test")
885 }
886 async fn send_user_message(
887 &self,
888 _: serde_json::Value,
889 ) -> Result<serde_json::Value, String> {
890 unreachable!("not under test")
891 }
892 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
893 unreachable!("not under test")
894 }
895 async fn set_session_name(
896 &self,
897 _: serde_json::Value,
898 ) -> Result<serde_json::Value, String> {
899 unreachable!("not under test")
900 }
901 async fn get_active_tools(
902 &self,
903 _: serde_json::Value,
904 ) -> Result<serde_json::Value, String> {
905 unreachable!("not under test")
906 }
907 async fn set_active_tools(
908 &self,
909 _: serde_json::Value,
910 ) -> Result<serde_json::Value, String> {
911 unreachable!("not under test")
912 }
913 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
914 unreachable!("not under test")
915 }
916 async fn get_thinking_level(
917 &self,
918 _: serde_json::Value,
919 ) -> Result<serde_json::Value, String> {
920 unreachable!("not under test")
921 }
922 async fn set_thinking_level(
923 &self,
924 _: serde_json::Value,
925 ) -> Result<serde_json::Value, String> {
926 unreachable!("not under test")
927 }
928 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
929 unreachable!("not under test")
930 }
931 async fn get_system_prompt(
932 &self,
933 _: serde_json::Value,
934 ) -> Result<serde_json::Value, String> {
935 self.saw
936 .lock()
937 .unwrap()
938 .push(RuntimeActionId::GetSystemPrompt);
939 Ok(serde_json::json!({ "prompt": self.prompt }))
940 }
941 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
942 unreachable!("not under test")
943 }
944 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
945 unreachable!("not under test")
946 }
947 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
948 unreachable!("not under test")
949 }
950 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
951 unreachable!("not under test")
952 }
953 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
954 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
955 Ok(serde_json::Value::Null)
956 }
957 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
958 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
959 Ok(serde_json::json!({ "value": true }))
960 }
961 }
962
963 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
972 async fn trampoline_round_trips_get_system_prompt() {
973 let host = Arc::new(MockHost {
974 prompt: "hello from host".to_string(),
975 saw: Mutex::new(Vec::new()),
976 });
977 let host_for_assert = Arc::clone(&host);
978 let host_dyn: Arc<dyn RuntimeActionHost> = host;
979 let runtime = tokio::runtime::Handle::current();
980 let bridge = ActionBridge::new(runtime, host_dyn);
981 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
982
983 let args_str = "{}";
985 let args_ref = StbStringRef::from_str(args_str);
986
987 let mut out = StbString::empty();
988 let rc = trampoline_runtime_action(
989 RuntimeActionId::GetSystemPrompt.into(),
990 args_ref,
991 &mut out as *mut StbString,
992 user_data,
993 );
994 assert_eq!(rc, 0, "success return code");
995
996 let json_text = out.to_string_lossy();
998 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
999 assert_eq!(parsed["prompt"], "hello from host");
1000 crate::host_free_string(out);
1001
1002 let saw = host_for_assert.saw.lock().unwrap().clone();
1005 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1006 }
1007
1008 #[tokio::test]
1009 async fn trampoline_null_user_data_returns_minus_one() {
1010 let args_ref = StbStringRef::from_str("{}");
1011 let mut out = StbString::empty();
1012 let rc = trampoline_runtime_action(
1013 RuntimeActionId::GetSystemPrompt.into(),
1014 args_ref,
1015 &mut out as *mut StbString,
1016 std::ptr::null_mut(),
1017 );
1018 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1019 }
1020
1021 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1022 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1023 use std::sync::atomic::{AtomicUsize, Ordering};
1027 let reload_calls = Arc::new(AtomicUsize::new(0));
1028 let reload_calls_for_cb = Arc::clone(&reload_calls);
1029 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1030 Arc::new(move || {
1031 let c = Arc::clone(&reload_calls_for_cb);
1032 Box::pin(async move {
1033 c.fetch_add(1, Ordering::SeqCst);
1034 })
1035 });
1036 let host = Arc::new(MockHost {
1037 prompt: String::new(),
1038 saw: Mutex::new(Vec::new()),
1039 });
1040 let host_for_assert = Arc::clone(&host);
1041 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1042 let runtime = tokio::runtime::Handle::current();
1043 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1044 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1045
1046 let args_ref = StbStringRef::from_str("{}");
1047 let mut out = StbString::empty();
1048 let rc = trampoline_runtime_action(
1049 RuntimeActionId::Reload.into(),
1050 args_ref,
1051 &mut out as *mut StbString,
1052 user_data,
1053 );
1054 assert_eq!(rc, 0);
1055 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1057 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1058 crate::host_free_string(out);
1059 }
1060
1061 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1066 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1067 let host = Arc::new(MockHost {
1068 prompt: String::new(),
1069 saw: Mutex::new(Vec::new()),
1070 });
1071 let host_for_assert = Arc::clone(&host);
1072 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1073 let runtime = tokio::runtime::Handle::current();
1074 let bridge = ActionBridge::new(runtime, host_dyn);
1075 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1076
1077 bridge.invalidate();
1079 assert!(!bridge.is_active());
1080
1081 let args_ref = StbStringRef::from_str("{}");
1082 let mut out = StbString::empty();
1083 let rc = trampoline_runtime_action(
1084 RuntimeActionId::GetSystemPrompt.into(),
1085 args_ref,
1086 &mut out as *mut StbString,
1087 user_data,
1088 );
1089 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1091 let json_text = out.to_string_lossy();
1092 assert!(
1093 json_text.contains("stale"),
1094 "stale-bridge error payload: {json_text}"
1095 );
1096 crate::host_free_string(out);
1097 assert!(
1098 host_for_assert.saw.lock().unwrap().is_empty(),
1099 "host dispatch must NOT run on a stale bridge"
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1105 let host = Arc::new(MockHost {
1106 prompt: String::new(),
1107 saw: Mutex::new(Vec::new()),
1108 });
1109 let host_for_assert = Arc::clone(&host);
1110 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1111 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1112 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1113
1114 let mut out = StbString::empty();
1115 let rc = trampoline_runtime_action(
1116 0xFFFF_FFFE,
1117 StbStringRef::from_str("{}"),
1118 &mut out,
1119 user_data,
1120 );
1121
1122 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1123 let payload: serde_json::Value =
1124 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1125 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1126 crate::host_free_string(out);
1127 assert!(
1128 host_for_assert.saw.lock().unwrap().is_empty(),
1129 "unknown action id must not reach host dispatch"
1130 );
1131 }
1132
1133 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1134 async fn v2_trampoline_accepts_new_action() {
1135 let host = Arc::new(MockHost {
1136 prompt: "v2".to_string(),
1137 saw: Mutex::new(Vec::new()),
1138 });
1139 let host_for_assert = Arc::clone(&host);
1140 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1141 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1142 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1143
1144 let mut v2_out = StbString::empty();
1145 assert_eq!(
1146 trampoline_runtime_action(
1147 RuntimeActionId::GetCliFlag.into(),
1148 StbStringRef::from_str(r#"{"name":"flag"}"#),
1149 &mut v2_out,
1150 user_data,
1151 ),
1152 0
1153 );
1154 crate::host_free_string(v2_out);
1155
1156 assert_eq!(
1157 *host_for_assert.saw.lock().unwrap(),
1158 vec![RuntimeActionId::GetCliFlag]
1159 );
1160 }
1161
1162 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1167 async fn reload_mailbox_signals_installed_receiver() {
1168 let mailbox = ReloadMailbox::new();
1169 assert!(matches!(mailbox.signal(), Err(())));
1171
1172 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1173 mailbox.install(tx);
1174 let cb = reload_callback_from_mailbox(mailbox.clone());
1175 cb().await;
1176 assert_eq!(
1177 rx.recv().await,
1178 Some(()),
1179 "installed receiver saw the signal"
1180 );
1181 mailbox.clear();
1182 }
1183
1184 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1187 serde_json::json!({
1188 "op": "open",
1189 "requestId": id,
1190 "toolCallId": tool_call,
1191 "ui": {"kind": "selector", "questions": [
1192 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1193 ]},
1194 })
1195 }
1196
1197 #[test]
1200 fn ui_dialog_open_requires_an_attached_ui() {
1201 let mailbox = UiDialogMailbox::new();
1202 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1203 assert!(error.contains("interactive UI"), "got: {error}");
1204 }
1205
1206 #[test]
1207 fn ui_dialog_open_poll_answer_round_trip() {
1208 let mailbox = UiDialogMailbox::new();
1209 mailbox.attach();
1210
1211 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1212 assert_eq!(opened["status"], "pending");
1213 assert_eq!(opened["toolCallId"], "c1");
1214
1215 let request = mailbox.take_pending().expect("request visible to TUI");
1216 assert_eq!(request.request_id, "r1");
1217 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1218 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1220 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1221
1222 mailbox
1223 .respond("r1", serde_json::json!("7890"))
1224 .expect("respond");
1225 let answered = mailbox.poll("r1").unwrap();
1226 assert_eq!(answered["status"], "answered");
1227 assert_eq!(answered["answer"], "7890");
1228 assert!(mailbox.poll("r1").is_err());
1230 }
1231
1232 #[test]
1233 fn ui_dialog_cancel_is_reported_once() {
1234 let mailbox = UiDialogMailbox::new();
1235 mailbox.attach();
1236 mailbox.handle(open_args("r1", "c1")).unwrap();
1237 mailbox.cancel("r1").unwrap();
1238 let cancelled = mailbox.poll("r1").unwrap();
1239 assert_eq!(cancelled["status"], "cancelled");
1240 assert!(mailbox.poll("r1").is_err());
1241 }
1242
1243 #[test]
1244 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1245 let mailbox = UiDialogMailbox::new();
1246 mailbox.attach();
1247 mailbox.handle(open_args("r1", "c1")).unwrap();
1248 mailbox.handle(open_args("r2", "c2")).unwrap();
1249
1250 let first = mailbox.take_pending().unwrap().request_id;
1251 let second = mailbox.take_pending().unwrap().request_id;
1252 assert_ne!(first, second);
1253
1254 mailbox
1256 .respond(&second, serde_json::json!("second"))
1257 .unwrap();
1258 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1259 let second_answer = mailbox.poll(&second).unwrap();
1260 assert_eq!(second_answer["answer"], "second");
1261
1262 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1263 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1264 }
1265
1266 #[test]
1267 fn ui_dialog_detach_cancels_outstanding() {
1268 let mailbox = UiDialogMailbox::new();
1269 mailbox.attach();
1270 mailbox.handle(open_args("r1", "c1")).unwrap();
1271 mailbox.detach();
1272 assert!(!mailbox.is_attached());
1273 assert_eq!(mailbox.pending_len(), 0);
1274 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1276 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1277 }
1278
1279 #[test]
1280 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1281 let mailbox = UiDialogMailbox::new();
1282 mailbox.attach();
1283 mailbox.handle(open_args("r1", "c1")).unwrap();
1284 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1285 assert_eq!(again["status"], "pending");
1286 assert_eq!(mailbox.pending_len(), 1);
1287 mailbox.take_pending().unwrap();
1288 assert!(mailbox.take_pending().is_none());
1289 }
1290
1291 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1295 async fn v2_dispatch_routes_ui_dialog_action_17() {
1296 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1297 prompt: String::new(),
1298 saw: Mutex::new(Vec::new()),
1299 });
1300 let bridge = ActionBridge::with_ui_dialog(
1301 tokio::runtime::Handle::current(),
1302 host,
1303 UiDialogMailbox::new(),
1304 );
1305 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1306
1307 let mut out = StbString::empty();
1308 let rc = trampoline_runtime_action(
1309 17,
1310 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1311 &mut out,
1312 user_data,
1313 );
1314 assert_eq!(rc, 1);
1315 let payload: serde_json::Value =
1316 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1317 assert!(
1318 payload["error"]
1319 .as_str()
1320 .is_some_and(|error| error.contains("interactive UI")),
1321 "unexpected payload: {payload}"
1322 );
1323 crate::host_free_string(out);
1324
1325 let mut out = StbString::empty();
1327 assert_eq!(
1328 trampoline_runtime_action(19, StbStringRef::from_str("{}"), &mut out, user_data),
1329 2
1330 );
1331 crate::host_free_string(out);
1332 }
1333
1334 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1337 async fn v2_dispatch_routes_set_status_action_18() {
1338 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1339 prompt: String::new(),
1340 saw: Mutex::new(Vec::new()),
1341 });
1342 let status = crate::status::ExtensionStatusMailbox::new();
1343 let bridge = ActionBridge::with_reload_and_ui(
1344 tokio::runtime::Handle::current(),
1345 host,
1346 Arc::new(|| Box::pin(async {})),
1347 UiDialogMailbox::new(),
1348 status.clone(),
1349 );
1350 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1351
1352 let mut out = StbString::empty();
1353 let rc = trampoline_runtime_action(
1354 18,
1355 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1356 &mut out,
1357 user_data,
1358 );
1359 assert_eq!(rc, 0);
1360 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1361 crate::host_free_string(out);
1362
1363 let mut out = StbString::empty();
1365 assert_eq!(
1366 trampoline_runtime_action(
1367 18,
1368 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1369 &mut out,
1370 user_data
1371 ),
1372 0
1373 );
1374 assert_eq!(status.text(), "");
1375 crate::host_free_string(out);
1376
1377 let mut out = StbString::empty();
1379 assert_eq!(
1380 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1381 1
1382 );
1383 crate::host_free_string(out);
1384 }
1385}