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 active: Arc<AtomicBool>,
403}
404
405impl ActionBridge {
406 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
409 Arc::new(Self {
410 runtime,
411 host,
412 reload: None,
413 ui_dialog: UiDialogMailbox::new(),
414 active: Arc::new(AtomicBool::new(true)),
415 })
416 }
417
418 pub fn with_reload(
421 runtime: Handle,
422 host: Arc<dyn RuntimeActionHost>,
423 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
424 ) -> Arc<Self> {
425 Self::with_reload_and_ui(runtime, host, reload, UiDialogMailbox::new())
426 }
427
428 pub fn with_ui_dialog(
430 runtime: Handle,
431 host: Arc<dyn RuntimeActionHost>,
432 ui_dialog: UiDialogMailbox,
433 ) -> Arc<Self> {
434 Arc::new(Self {
435 runtime,
436 host,
437 reload: None,
438 ui_dialog,
439 active: Arc::new(AtomicBool::new(true)),
440 })
441 }
442
443 pub fn with_reload_and_ui(
445 runtime: Handle,
446 host: Arc<dyn RuntimeActionHost>,
447 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
448 ui_dialog: UiDialogMailbox,
449 ) -> Arc<Self> {
450 Arc::new(Self {
451 runtime,
452 host,
453 reload: Some(reload),
454 ui_dialog,
455 active: Arc::new(AtomicBool::new(true)),
456 })
457 }
458
459 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
461 self.ui_dialog.clone()
462 }
463
464 pub fn invalidate(&self) {
469 self.active.store(false, Ordering::SeqCst);
470 }
471
472 pub fn is_active(&self) -> bool {
474 self.active.load(Ordering::SeqCst)
475 }
476
477 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
484 Arc::clone(&self.host)
485 }
486}
487
488#[derive(Clone)]
501pub struct ReloadMailbox {
502 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
503}
504
505impl Default for ReloadMailbox {
506 fn default() -> Self {
507 Self {
508 tx: Arc::new(std::sync::Mutex::new(None)),
509 }
510 }
511}
512
513impl ReloadMailbox {
514 pub fn new() -> Self {
516 Self::default()
517 }
518
519 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
523 *self.tx.lock().unwrap() = Some(tx);
524 }
525
526 pub fn signal(&self) -> Result<(), ()> {
531 let g = self.tx.lock().unwrap();
532 match &*g {
533 Some(tx) => {
534 let _ = tx.send(());
535 Ok(())
536 }
537 None => Err(()),
538 }
539 }
540
541 pub fn clear(&self) {
543 *self.tx.lock().unwrap() = None;
544 }
545}
546
547pub fn reload_callback_from_mailbox(
554 mailbox: ReloadMailbox,
555) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
556 Arc::new(move || {
557 let m = mailbox.clone();
558 Box::pin(async move {
559 let _ = m.signal();
560 })
561 })
562}
563
564async fn dispatch(
575 host: &Arc<dyn RuntimeActionHost>,
576 ui_dialog: &UiDialogMailbox,
577 action: RuntimeActionId,
578 args: serde_json::Value,
579) -> Result<serde_json::Value, String> {
580 match action {
581 RuntimeActionId::SendMessage => host.send_message(args).await,
582 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
583 RuntimeActionId::AppendEntry => host.append_entry(args).await,
584 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
585 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
586 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
587 RuntimeActionId::SetModel => host.set_model(args).await,
588 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
589 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
590 RuntimeActionId::Compact => host.compact(args).await,
591 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
592 RuntimeActionId::NewSession => host.new_session(args).await,
593 RuntimeActionId::Fork => host.fork(args).await,
594 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
595 RuntimeActionId::SwitchSession => host.switch_session(args).await,
596 RuntimeActionId::Reload => host.reload(args).await,
597 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
598 RuntimeActionId::UiDialog => ui_dialog.handle(args),
599 }
600}
601
602pub extern "C" fn trampoline_runtime_action(
625 action_id: u32,
626 args_json: StbStringRef,
627 out: *mut StbString,
628 user_data: *mut c_void,
629) -> i32 {
630 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
631 run_action(
632 action_id,
633 u32::from(RuntimeActionId::UiDialog),
634 args_json,
635 out,
636 user_data,
637 )
638 }));
639 match outcome {
640 Ok(rc) => rc,
641 Err(_) => {
642 tracing::error!(
643 "runtime_action trampoline panicked — aborting (cannot unwind across FFI)"
644 );
645 std::process::abort();
646 }
647 }
648}
649
650pub extern "C" fn trampoline_runtime_action_v1(
653 action_id: u32,
654 args_json: StbStringRef,
655 out: *mut StbString,
656 user_data: *mut c_void,
657) -> i32 {
658 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
659 run_action(
660 action_id,
661 u32::from(RuntimeActionId::Reload),
662 args_json,
663 out,
664 user_data,
665 )
666 }));
667 match outcome {
668 Ok(rc) => rc,
669 Err(_) => {
670 tracing::error!(
671 "ABI v1 runtime_action trampoline panicked - aborting (cannot unwind across FFI)"
672 );
673 std::process::abort();
674 }
675 }
676}
677
678fn run_action(
680 action_id: u32,
681 max_action_id: u32,
682 args_json: StbStringRef,
683 out: *mut StbString,
684 user_data: *mut c_void,
685) -> i32 {
686 let action = match RuntimeActionId::try_from(action_id) {
690 Ok(action) if action_id <= max_action_id => action,
691 Ok(_) | Err(_) => {
692 if !out.is_null() {
693 let json = serde_json::json!({
694 "error": format!("unknown runtime action id {action_id}")
695 })
696 .to_string();
697 unsafe {
701 *out = StbString::from_string(json);
702 }
703 }
704 return 2;
705 }
706 };
707
708 if user_data.is_null() {
709 return -1;
710 }
711 let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
716
717 if !bridge.is_active() {
725 if out.is_null() {
726 return 1;
727 }
728 let json = serde_json::json!({
729 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
730 })
731 .to_string();
732 unsafe {
733 *out = StbString::from_string(json);
734 }
735 return 1;
736 }
737
738 let args_str = unsafe { args_json.as_str() };
741 let args: serde_json::Value = if args_str.is_empty() {
742 serde_json::Value::Object(serde_json::Map::new())
743 } else {
744 serde_json::from_str(args_str)
745 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
746 };
747
748 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
753 let host = Arc::clone(&bridge.host);
757 let ui_dialog = bridge.ui_dialog.clone();
758 let reload_cb = bridge.reload.clone();
759 if action == RuntimeActionId::UiDialog {
760 std::thread::spawn(move || {
766 let _ = tx.send(ui_dialog.handle(args));
767 });
768 } else {
769 bridge.runtime.spawn(async move {
770 let r = if action == RuntimeActionId::Reload {
771 if let Some(cb) = reload_cb {
772 cb().await;
773 Ok(serde_json::Value::Null)
774 } else {
775 host.reload(args).await
776 }
777 } else {
778 dispatch(&host, &ui_dialog, action, args).await
779 };
780 let _ = tx.send(r);
783 });
784 }
785
786 let result = match rx.recv() {
787 Ok(r) => r,
788 Err(_) => {
789 return -2;
794 }
795 };
796
797 if out.is_null() {
800 return match result {
802 Ok(_) => 0,
803 Err(_) => 1,
804 };
805 }
806
807 let (rc, payload) = match result {
808 Ok(value) => {
809 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
810 (0, json)
811 }
812 Err(msg) => {
813 let json = serde_json::json!({ "error": msg }).to_string();
814 (1, json)
815 }
816 };
817 unsafe {
823 *out = StbString::from_string(payload);
824 }
825 rc
826}
827
828#[cfg(test)]
837mod tests {
838 use super::*;
839 use std::sync::Mutex;
840
841 struct MockHost {
846 prompt: String,
847 saw: Mutex<Vec<RuntimeActionId>>,
848 }
849
850 #[async_trait::async_trait]
851 impl RuntimeActionHost for MockHost {
852 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
853 unreachable!("not under test")
854 }
855 async fn send_user_message(
856 &self,
857 _: serde_json::Value,
858 ) -> Result<serde_json::Value, String> {
859 unreachable!("not under test")
860 }
861 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
862 unreachable!("not under test")
863 }
864 async fn set_session_name(
865 &self,
866 _: serde_json::Value,
867 ) -> Result<serde_json::Value, String> {
868 unreachable!("not under test")
869 }
870 async fn get_active_tools(
871 &self,
872 _: serde_json::Value,
873 ) -> Result<serde_json::Value, String> {
874 unreachable!("not under test")
875 }
876 async fn set_active_tools(
877 &self,
878 _: serde_json::Value,
879 ) -> Result<serde_json::Value, String> {
880 unreachable!("not under test")
881 }
882 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
883 unreachable!("not under test")
884 }
885 async fn get_thinking_level(
886 &self,
887 _: serde_json::Value,
888 ) -> Result<serde_json::Value, String> {
889 unreachable!("not under test")
890 }
891 async fn set_thinking_level(
892 &self,
893 _: serde_json::Value,
894 ) -> Result<serde_json::Value, String> {
895 unreachable!("not under test")
896 }
897 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
898 unreachable!("not under test")
899 }
900 async fn get_system_prompt(
901 &self,
902 _: serde_json::Value,
903 ) -> Result<serde_json::Value, String> {
904 self.saw
905 .lock()
906 .unwrap()
907 .push(RuntimeActionId::GetSystemPrompt);
908 Ok(serde_json::json!({ "prompt": self.prompt }))
909 }
910 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
911 unreachable!("not under test")
912 }
913 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
914 unreachable!("not under test")
915 }
916 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
917 unreachable!("not under test")
918 }
919 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
920 unreachable!("not under test")
921 }
922 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
923 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
924 Ok(serde_json::Value::Null)
925 }
926 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
927 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
928 Ok(serde_json::json!({ "value": true }))
929 }
930 }
931
932 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
941 async fn trampoline_round_trips_get_system_prompt() {
942 let host = Arc::new(MockHost {
943 prompt: "hello from host".to_string(),
944 saw: Mutex::new(Vec::new()),
945 });
946 let host_for_assert = Arc::clone(&host);
947 let host_dyn: Arc<dyn RuntimeActionHost> = host;
948 let runtime = tokio::runtime::Handle::current();
949 let bridge = ActionBridge::new(runtime, host_dyn);
950 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
951
952 let args_str = "{}";
954 let args_ref = StbStringRef::from_str(args_str);
955
956 let mut out = StbString::empty();
957 let rc = trampoline_runtime_action(
958 RuntimeActionId::GetSystemPrompt.into(),
959 args_ref,
960 &mut out as *mut StbString,
961 user_data,
962 );
963 assert_eq!(rc, 0, "success return code");
964
965 let json_text = out.to_string_lossy();
967 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
968 assert_eq!(parsed["prompt"], "hello from host");
969 crate::host_free_string(out);
970
971 let saw = host_for_assert.saw.lock().unwrap().clone();
974 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
975 }
976
977 #[tokio::test]
978 async fn trampoline_null_user_data_returns_minus_one() {
979 let args_ref = StbStringRef::from_str("{}");
980 let mut out = StbString::empty();
981 let rc = trampoline_runtime_action(
982 RuntimeActionId::GetSystemPrompt.into(),
983 args_ref,
984 &mut out as *mut StbString,
985 std::ptr::null_mut(),
986 );
987 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
988 }
989
990 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
991 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
992 use std::sync::atomic::{AtomicUsize, Ordering};
996 let reload_calls = Arc::new(AtomicUsize::new(0));
997 let reload_calls_for_cb = Arc::clone(&reload_calls);
998 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
999 Arc::new(move || {
1000 let c = Arc::clone(&reload_calls_for_cb);
1001 Box::pin(async move {
1002 c.fetch_add(1, Ordering::SeqCst);
1003 })
1004 });
1005 let host = Arc::new(MockHost {
1006 prompt: String::new(),
1007 saw: Mutex::new(Vec::new()),
1008 });
1009 let host_for_assert = Arc::clone(&host);
1010 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1011 let runtime = tokio::runtime::Handle::current();
1012 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1013 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1014
1015 let args_ref = StbStringRef::from_str("{}");
1016 let mut out = StbString::empty();
1017 let rc = trampoline_runtime_action(
1018 RuntimeActionId::Reload.into(),
1019 args_ref,
1020 &mut out as *mut StbString,
1021 user_data,
1022 );
1023 assert_eq!(rc, 0);
1024 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1026 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1027 crate::host_free_string(out);
1028 }
1029
1030 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1035 async fn trampoline_rejects_stale_bridge_with_invalidate() {
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::new(runtime, host_dyn);
1044 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1045
1046 bridge.invalidate();
1048 assert!(!bridge.is_active());
1049
1050 let args_ref = StbStringRef::from_str("{}");
1051 let mut out = StbString::empty();
1052 let rc = trampoline_runtime_action(
1053 RuntimeActionId::GetSystemPrompt.into(),
1054 args_ref,
1055 &mut out as *mut StbString,
1056 user_data,
1057 );
1058 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1060 let json_text = out.to_string_lossy();
1061 assert!(
1062 json_text.contains("stale"),
1063 "stale-bridge error payload: {json_text}"
1064 );
1065 crate::host_free_string(out);
1066 assert!(
1067 host_for_assert.saw.lock().unwrap().is_empty(),
1068 "host dispatch must NOT run on a stale bridge"
1069 );
1070 }
1071
1072 #[tokio::test]
1073 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1074 let host = Arc::new(MockHost {
1075 prompt: String::new(),
1076 saw: Mutex::new(Vec::new()),
1077 });
1078 let host_for_assert = Arc::clone(&host);
1079 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1080 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1081 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1082
1083 let mut out = StbString::empty();
1084 let rc = trampoline_runtime_action(
1085 0xFFFF_FFFE,
1086 StbStringRef::from_str("{}"),
1087 &mut out,
1088 user_data,
1089 );
1090
1091 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1092 let payload: serde_json::Value =
1093 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1094 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1095 crate::host_free_string(out);
1096 assert!(
1097 host_for_assert.saw.lock().unwrap().is_empty(),
1098 "unknown action id must not reach host dispatch"
1099 );
1100 }
1101
1102 #[tokio::test]
1103 async fn legacy_trampoline_rejects_v2_action_without_dispatch() {
1104 let host = Arc::new(MockHost {
1105 prompt: String::new(),
1106 saw: Mutex::new(Vec::new()),
1107 });
1108 let host_for_assert = Arc::clone(&host);
1109 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1110 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1111 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1112
1113 let mut out = StbString::empty();
1114 let rc = trampoline_runtime_action_v1(
1115 RuntimeActionId::GetCliFlag.into(),
1116 StbStringRef::from_str("{}"),
1117 &mut out,
1118 user_data,
1119 );
1120
1121 assert_eq!(rc, 2);
1122 let payload: serde_json::Value =
1123 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1124 assert_eq!(payload["error"], "unknown runtime action id 16");
1125 crate::host_free_string(out);
1126 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1127 }
1128
1129 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1130 async fn legacy_accepts_v1_action_and_v2_accepts_new_action() {
1131 let host = Arc::new(MockHost {
1132 prompt: "legacy".to_string(),
1133 saw: Mutex::new(Vec::new()),
1134 });
1135 let host_for_assert = Arc::clone(&host);
1136 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1137 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1138 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1139
1140 let mut legacy_out = StbString::empty();
1141 assert_eq!(
1142 trampoline_runtime_action_v1(
1143 RuntimeActionId::GetSystemPrompt.into(),
1144 StbStringRef::from_str("{}"),
1145 &mut legacy_out,
1146 user_data,
1147 ),
1148 0
1149 );
1150 crate::host_free_string(legacy_out);
1151
1152 let mut v2_out = StbString::empty();
1153 assert_eq!(
1154 trampoline_runtime_action(
1155 RuntimeActionId::GetCliFlag.into(),
1156 StbStringRef::from_str(r#"{"name":"flag"}"#),
1157 &mut v2_out,
1158 user_data,
1159 ),
1160 0
1161 );
1162 crate::host_free_string(v2_out);
1163
1164 assert_eq!(
1165 *host_for_assert.saw.lock().unwrap(),
1166 vec![
1167 RuntimeActionId::GetSystemPrompt,
1168 RuntimeActionId::GetCliFlag
1169 ]
1170 );
1171 }
1172
1173 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1178 async fn reload_mailbox_signals_installed_receiver() {
1179 let mailbox = ReloadMailbox::new();
1180 assert!(matches!(mailbox.signal(), Err(())));
1182
1183 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1184 mailbox.install(tx);
1185 let cb = reload_callback_from_mailbox(mailbox.clone());
1186 cb().await;
1187 assert_eq!(
1188 rx.recv().await,
1189 Some(()),
1190 "installed receiver saw the signal"
1191 );
1192 mailbox.clear();
1193 }
1194
1195 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1198 serde_json::json!({
1199 "op": "open",
1200 "requestId": id,
1201 "toolCallId": tool_call,
1202 "ui": {"kind": "selector", "questions": [
1203 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1204 ]},
1205 })
1206 }
1207
1208 #[test]
1211 fn ui_dialog_open_requires_an_attached_ui() {
1212 let mailbox = UiDialogMailbox::new();
1213 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1214 assert!(error.contains("interactive UI"), "got: {error}");
1215 }
1216
1217 #[test]
1218 fn ui_dialog_open_poll_answer_round_trip() {
1219 let mailbox = UiDialogMailbox::new();
1220 mailbox.attach();
1221
1222 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1223 assert_eq!(opened["status"], "pending");
1224 assert_eq!(opened["toolCallId"], "c1");
1225
1226 let request = mailbox.take_pending().expect("request visible to TUI");
1227 assert_eq!(request.request_id, "r1");
1228 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1229 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1231 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1232
1233 mailbox
1234 .respond("r1", serde_json::json!("7890"))
1235 .expect("respond");
1236 let answered = mailbox.poll("r1").unwrap();
1237 assert_eq!(answered["status"], "answered");
1238 assert_eq!(answered["answer"], "7890");
1239 assert!(mailbox.poll("r1").is_err());
1241 }
1242
1243 #[test]
1244 fn ui_dialog_cancel_is_reported_once() {
1245 let mailbox = UiDialogMailbox::new();
1246 mailbox.attach();
1247 mailbox.handle(open_args("r1", "c1")).unwrap();
1248 mailbox.cancel("r1").unwrap();
1249 let cancelled = mailbox.poll("r1").unwrap();
1250 assert_eq!(cancelled["status"], "cancelled");
1251 assert!(mailbox.poll("r1").is_err());
1252 }
1253
1254 #[test]
1255 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1256 let mailbox = UiDialogMailbox::new();
1257 mailbox.attach();
1258 mailbox.handle(open_args("r1", "c1")).unwrap();
1259 mailbox.handle(open_args("r2", "c2")).unwrap();
1260
1261 let first = mailbox.take_pending().unwrap().request_id;
1262 let second = mailbox.take_pending().unwrap().request_id;
1263 assert_ne!(first, second);
1264
1265 mailbox.respond(&second, serde_json::json!("second")).unwrap();
1267 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1268 let second_answer = mailbox.poll(&second).unwrap();
1269 assert_eq!(second_answer["answer"], "second");
1270
1271 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1272 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1273 }
1274
1275 #[test]
1276 fn ui_dialog_detach_cancels_outstanding() {
1277 let mailbox = UiDialogMailbox::new();
1278 mailbox.attach();
1279 mailbox.handle(open_args("r1", "c1")).unwrap();
1280 mailbox.detach();
1281 assert!(!mailbox.is_attached());
1282 assert_eq!(mailbox.pending_len(), 0);
1283 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1285 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1286 }
1287
1288 #[test]
1289 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1290 let mailbox = UiDialogMailbox::new();
1291 mailbox.attach();
1292 mailbox.handle(open_args("r1", "c1")).unwrap();
1293 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1294 assert_eq!(again["status"], "pending");
1295 assert_eq!(mailbox.pending_len(), 1);
1296 mailbox.take_pending().unwrap();
1297 assert!(mailbox.take_pending().is_none());
1298 }
1299
1300 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1304 async fn v2_dispatch_routes_ui_dialog_action_17() {
1305 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1306 prompt: String::new(),
1307 saw: Mutex::new(Vec::new()),
1308 });
1309 let bridge = ActionBridge::with_ui_dialog(
1310 tokio::runtime::Handle::current(),
1311 host,
1312 UiDialogMailbox::new(),
1313 );
1314 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1315
1316 let mut out = StbString::empty();
1317 let rc = trampoline_runtime_action(
1318 17,
1319 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1320 &mut out,
1321 user_data,
1322 );
1323 assert_eq!(rc, 1);
1324 let payload: serde_json::Value =
1325 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1326 assert!(
1327 payload["error"]
1328 .as_str()
1329 .is_some_and(|error| error.contains("interactive UI")),
1330 "unexpected payload: {payload}"
1331 );
1332 crate::host_free_string(out);
1333
1334 let mut out = StbString::empty();
1336 assert_eq!(
1337 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1338 2
1339 );
1340 crate::host_free_string(out);
1341 }
1342}