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 async fn get_session_branch(
380 &self,
381 _args: serde_json::Value,
382 ) -> Result<serde_json::Value, String> {
383 Err("session branch lookup is not configured".to_string())
384 }
385}
386
387pub struct ActionBridge {
416 pub(crate) runtime: Handle,
418 pub(crate) host: Arc<dyn RuntimeActionHost>,
420 pub(crate) reload:
422 Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
423 ui_dialog: UiDialogMailbox,
426 ext_status: crate::status::ExtensionStatusMailbox,
430 editor_text: crate::editor_text::EditorTextMailbox,
434 active: Arc<AtomicBool>,
437}
438
439impl ActionBridge {
440 pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
443 Arc::new(Self {
444 runtime,
445 host,
446 reload: None,
447 ui_dialog: UiDialogMailbox::new(),
448 ext_status: crate::status::ExtensionStatusMailbox::new(),
449 editor_text: crate::editor_text::EditorTextMailbox::new(),
450 active: Arc::new(AtomicBool::new(true)),
451 })
452 }
453
454 pub fn with_reload(
457 runtime: Handle,
458 host: Arc<dyn RuntimeActionHost>,
459 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
460 ) -> Arc<Self> {
461 Self::with_reload_and_ui(
462 runtime,
463 host,
464 reload,
465 UiDialogMailbox::new(),
466 crate::status::ExtensionStatusMailbox::new(),
467 crate::editor_text::EditorTextMailbox::new(),
468 )
469 }
470
471 pub fn with_ui_dialog(
473 runtime: Handle,
474 host: Arc<dyn RuntimeActionHost>,
475 ui_dialog: UiDialogMailbox,
476 ) -> Arc<Self> {
477 Arc::new(Self {
478 runtime,
479 host,
480 reload: None,
481 ui_dialog,
482 ext_status: crate::status::ExtensionStatusMailbox::new(),
483 editor_text: crate::editor_text::EditorTextMailbox::new(),
484 active: Arc::new(AtomicBool::new(true)),
485 })
486 }
487
488 pub fn with_reload_and_ui(
490 runtime: Handle,
491 host: Arc<dyn RuntimeActionHost>,
492 reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
493 ui_dialog: UiDialogMailbox,
494 ext_status: crate::status::ExtensionStatusMailbox,
495 editor_text: crate::editor_text::EditorTextMailbox,
496 ) -> Arc<Self> {
497 Arc::new(Self {
498 runtime,
499 host,
500 reload: Some(reload),
501 ui_dialog,
502 ext_status,
503 editor_text,
504 active: Arc::new(AtomicBool::new(true)),
505 })
506 }
507
508 pub fn ui_dialog_mailbox(&self) -> UiDialogMailbox {
510 self.ui_dialog.clone()
511 }
512
513 pub fn extension_status_mailbox(&self) -> crate::status::ExtensionStatusMailbox {
515 self.ext_status.clone()
516 }
517
518 pub fn editor_text_mailbox(&self) -> crate::editor_text::EditorTextMailbox {
520 self.editor_text.clone()
521 }
522
523 pub fn invalidate(&self) {
528 self.active.store(false, Ordering::SeqCst);
529 }
530
531 pub fn is_active(&self) -> bool {
533 self.active.load(Ordering::SeqCst)
534 }
535
536 pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
543 Arc::clone(&self.host)
544 }
545}
546
547#[derive(Clone)]
560pub struct ReloadMailbox {
561 tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
562}
563
564impl Default for ReloadMailbox {
565 fn default() -> Self {
566 Self {
567 tx: Arc::new(std::sync::Mutex::new(None)),
568 }
569 }
570}
571
572impl ReloadMailbox {
573 pub fn new() -> Self {
575 Self::default()
576 }
577
578 pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
582 *self.tx.lock().unwrap() = Some(tx);
583 }
584
585 pub fn signal(&self) -> Result<(), ()> {
590 let g = self.tx.lock().unwrap();
591 match &*g {
592 Some(tx) => {
593 let _ = tx.send(());
594 Ok(())
595 }
596 None => Err(()),
597 }
598 }
599
600 pub fn clear(&self) {
602 *self.tx.lock().unwrap() = None;
603 }
604}
605
606pub fn reload_callback_from_mailbox(
613 mailbox: ReloadMailbox,
614) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
615 Arc::new(move || {
616 let m = mailbox.clone();
617 Box::pin(async move {
618 let _ = m.signal();
619 })
620 })
621}
622
623async fn dispatch(
634 host: &Arc<dyn RuntimeActionHost>,
635 ui_dialog: &UiDialogMailbox,
636 ext_status: &crate::status::ExtensionStatusMailbox,
637 editor_text: &crate::editor_text::EditorTextMailbox,
638 action: RuntimeActionId,
639 args: serde_json::Value,
640) -> Result<serde_json::Value, String> {
641 match action {
642 RuntimeActionId::SendMessage => host.send_message(args).await,
643 RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
644 RuntimeActionId::AppendEntry => host.append_entry(args).await,
645 RuntimeActionId::SetSessionName => host.set_session_name(args).await,
646 RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
647 RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
648 RuntimeActionId::SetModel => host.set_model(args).await,
649 RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
650 RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
651 RuntimeActionId::Compact => host.compact(args).await,
652 RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
653 RuntimeActionId::NewSession => host.new_session(args).await,
654 RuntimeActionId::Fork => host.fork(args).await,
655 RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
656 RuntimeActionId::SwitchSession => host.switch_session(args).await,
657 RuntimeActionId::Reload => host.reload(args).await,
658 RuntimeActionId::GetCliFlag => host.get_cli_flag(args).await,
659 RuntimeActionId::UiDialog => ui_dialog.handle(args),
660 RuntimeActionId::SetStatus => ext_status.handle(args),
663 RuntimeActionId::SetEditorText => editor_text.handle(args),
666 RuntimeActionId::GetSessionBranch => host.get_session_branch(args).await,
667 }
668}
669
670pub extern "C" fn trampoline_runtime_action(
693 action_id: u32,
694 args_json: StbStringRef,
695 out: *mut StbString,
696 user_data: *mut c_void,
697) -> i32 {
698 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
699 run_action(
700 action_id,
701 u32::from(RuntimeActionId::SetEditorText),
703 args_json,
704 out,
705 user_data,
706 )
707 }));
708 match outcome {
709 Ok(rc) => rc,
710 Err(_) => {
711 tracing::error!("runtime_action trampoline panicked — reporting host error");
715 if !out.is_null() {
716 unsafe {
717 *out = StbString::from_string(
718 r#"{"error":"runtime_action panicked"}"#.to_string(),
719 );
720 }
721 }
722 1
723 }
724 }
725}
726
727fn run_action(
729 action_id: u32,
730 max_action_id: u32,
731 args_json: StbStringRef,
732 out: *mut StbString,
733 user_data: *mut c_void,
734) -> i32 {
735 let action = match RuntimeActionId::try_from(action_id) {
739 Ok(action) if action_id <= max_action_id => action,
740 Ok(_) | Err(_) => {
741 if !out.is_null() {
742 let json = serde_json::json!({
743 "error": format!("unknown runtime action id {action_id}")
744 })
745 .to_string();
746 unsafe {
750 *out = StbString::from_string(json);
751 }
752 }
753 return 2;
754 }
755 };
756
757 if user_data.is_null() {
758 return -1;
759 }
760 let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
765
766 if !bridge.is_active() {
774 if out.is_null() {
775 return 1;
776 }
777 let json = serde_json::json!({
778 "error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
779 })
780 .to_string();
781 unsafe {
782 *out = StbString::from_string(json);
783 }
784 return 1;
785 }
786
787 let args_str = unsafe { args_json.as_str() };
790 let args: serde_json::Value = if args_str.is_empty() {
791 serde_json::Value::Object(serde_json::Map::new())
792 } else {
793 serde_json::from_str(args_str)
794 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
795 };
796
797 let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
802 let host = Arc::clone(&bridge.host);
806 let ui_dialog = bridge.ui_dialog.clone();
807 let ext_status = bridge.ext_status.clone();
808 let editor_text = bridge.editor_text.clone();
809 let reload_cb = bridge.reload.clone();
810 if action == RuntimeActionId::SetStatus {
811 std::thread::spawn(move || {
816 let _ = tx.send(ext_status.handle(args));
817 });
818 } else if action == RuntimeActionId::SetEditorText {
819 std::thread::spawn(move || {
822 let _ = tx.send(editor_text.handle(args));
823 });
824 } else if action == RuntimeActionId::UiDialog {
825 std::thread::spawn(move || {
831 let _ = tx.send(ui_dialog.handle(args));
832 });
833 } else {
834 bridge.runtime.spawn(async move {
835 let r = if action == RuntimeActionId::Reload {
836 if let Some(cb) = reload_cb {
837 cb().await;
838 Ok(serde_json::Value::Null)
839 } else {
840 host.reload(args).await
841 }
842 } else {
843 dispatch(&host, &ui_dialog, &ext_status, &editor_text, action, args).await
844 };
845 let _ = tx.send(r);
848 });
849 }
850
851 let result = match rx.recv() {
852 Ok(r) => r,
853 Err(_) => {
854 return -2;
859 }
860 };
861
862 if out.is_null() {
865 return match result {
867 Ok(_) => 0,
868 Err(_) => 1,
869 };
870 }
871
872 let (rc, payload) = match result {
873 Ok(value) => {
874 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
875 (0, json)
876 }
877 Err(msg) => {
878 let json = serde_json::json!({ "error": msg }).to_string();
879 (1, json)
880 }
881 };
882 unsafe {
888 *out = StbString::from_string(payload);
889 }
890 rc
891}
892
893#[cfg(test)]
902mod tests {
903 use super::*;
904 use std::sync::Mutex;
905
906 struct MockHost {
911 prompt: String,
912 saw: Mutex<Vec<RuntimeActionId>>,
913 }
914
915 #[async_trait::async_trait]
916 impl RuntimeActionHost for MockHost {
917 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
918 unreachable!("not under test")
919 }
920 async fn send_user_message(
921 &self,
922 _: serde_json::Value,
923 ) -> Result<serde_json::Value, String> {
924 unreachable!("not under test")
925 }
926 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
927 unreachable!("not under test")
928 }
929 async fn set_session_name(
930 &self,
931 _: serde_json::Value,
932 ) -> Result<serde_json::Value, String> {
933 unreachable!("not under test")
934 }
935 async fn get_active_tools(
936 &self,
937 _: serde_json::Value,
938 ) -> Result<serde_json::Value, String> {
939 unreachable!("not under test")
940 }
941 async fn set_active_tools(
942 &self,
943 _: serde_json::Value,
944 ) -> Result<serde_json::Value, String> {
945 unreachable!("not under test")
946 }
947 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
948 unreachable!("not under test")
949 }
950 async fn get_thinking_level(
951 &self,
952 _: serde_json::Value,
953 ) -> Result<serde_json::Value, String> {
954 unreachable!("not under test")
955 }
956 async fn set_thinking_level(
957 &self,
958 _: serde_json::Value,
959 ) -> Result<serde_json::Value, String> {
960 unreachable!("not under test")
961 }
962 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
963 unreachable!("not under test")
964 }
965 async fn get_system_prompt(
966 &self,
967 _: serde_json::Value,
968 ) -> Result<serde_json::Value, String> {
969 self.saw
970 .lock()
971 .unwrap()
972 .push(RuntimeActionId::GetSystemPrompt);
973 Ok(serde_json::json!({ "prompt": self.prompt }))
974 }
975 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
976 unreachable!("not under test")
977 }
978 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
979 unreachable!("not under test")
980 }
981 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
982 unreachable!("not under test")
983 }
984 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
985 unreachable!("not under test")
986 }
987 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
988 self.saw.lock().unwrap().push(RuntimeActionId::Reload);
989 Ok(serde_json::Value::Null)
990 }
991 async fn get_cli_flag(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
992 self.saw.lock().unwrap().push(RuntimeActionId::GetCliFlag);
993 Ok(serde_json::json!({ "value": true }))
994 }
995 }
996
997 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1006 async fn trampoline_round_trips_get_system_prompt() {
1007 let host = Arc::new(MockHost {
1008 prompt: "hello from host".to_string(),
1009 saw: Mutex::new(Vec::new()),
1010 });
1011 let host_for_assert = Arc::clone(&host);
1012 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1013 let runtime = tokio::runtime::Handle::current();
1014 let bridge = ActionBridge::new(runtime, host_dyn);
1015 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1016
1017 let args_str = "{}";
1019 let args_ref = StbStringRef::from_str(args_str);
1020
1021 let mut out = StbString::empty();
1022 let rc = trampoline_runtime_action(
1023 RuntimeActionId::GetSystemPrompt.into(),
1024 args_ref,
1025 &mut out as *mut StbString,
1026 user_data,
1027 );
1028 assert_eq!(rc, 0, "success return code");
1029
1030 let json_text = out.to_string_lossy();
1032 let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
1033 assert_eq!(parsed["prompt"], "hello from host");
1034 crate::host_free_string(out);
1035
1036 let saw = host_for_assert.saw.lock().unwrap().clone();
1039 assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
1040 }
1041
1042 #[tokio::test]
1048 async fn trampoline_routes_set_editor_text_into_the_mailbox() {
1049 let host = Arc::new(MockHost {
1050 prompt: String::new(),
1051 saw: Mutex::new(Vec::new()),
1052 });
1053 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1054 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1055 let mailbox = bridge.editor_text_mailbox();
1056 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1057
1058 let args_ref =
1059 StbStringRef::from_str(r#"{"text":"from voice","mode":"append","autoSendMs":2000}"#);
1060 let mut out = StbString::empty();
1061 let rc = trampoline_runtime_action(
1062 RuntimeActionId::SetEditorText.into(),
1063 args_ref,
1064 &mut out as *mut StbString,
1065 user_data,
1066 );
1067 assert_eq!(rc, 0, "success return code");
1068
1069 let parsed: serde_json::Value =
1070 serde_json::from_str(&out.to_string_lossy()).expect("valid json");
1071 assert_eq!(parsed["ok"], serde_json::json!(true));
1072 assert_eq!(parsed["chars"], serde_json::json!(10));
1073 crate::host_free_string(out);
1074
1075 let edit = mailbox.take_pending().expect("queued edit");
1077 assert_eq!(edit.text, "from voice");
1078 assert_eq!(edit.mode, crate::EditorTextMode::Append);
1079 assert_eq!(edit.auto_send_ms, Some(2000));
1080 assert!(mailbox.take_pending().is_none());
1081 }
1082
1083 #[tokio::test]
1084 async fn trampoline_null_user_data_returns_minus_one() {
1085 let args_ref = StbStringRef::from_str("{}");
1086 let mut out = StbString::empty();
1087 let rc = trampoline_runtime_action(
1088 RuntimeActionId::GetSystemPrompt.into(),
1089 args_ref,
1090 &mut out as *mut StbString,
1091 std::ptr::null_mut(),
1092 );
1093 assert_eq!(rc, -1, "null user_data ⇒ no bridge");
1094 }
1095
1096 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1097 async fn trampoline_intercepts_reload_when_bridge_has_callback() {
1098 use std::sync::atomic::{AtomicUsize, Ordering};
1102 let reload_calls = Arc::new(AtomicUsize::new(0));
1103 let reload_calls_for_cb = Arc::clone(&reload_calls);
1104 let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
1105 Arc::new(move || {
1106 let c = Arc::clone(&reload_calls_for_cb);
1107 Box::pin(async move {
1108 c.fetch_add(1, Ordering::SeqCst);
1109 })
1110 });
1111 let host = Arc::new(MockHost {
1112 prompt: String::new(),
1113 saw: Mutex::new(Vec::new()),
1114 });
1115 let host_for_assert = Arc::clone(&host);
1116 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1117 let runtime = tokio::runtime::Handle::current();
1118 let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
1119 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1120
1121 let args_ref = StbStringRef::from_str("{}");
1122 let mut out = StbString::empty();
1123 let rc = trampoline_runtime_action(
1124 RuntimeActionId::Reload.into(),
1125 args_ref,
1126 &mut out as *mut StbString,
1127 user_data,
1128 );
1129 assert_eq!(rc, 0);
1130 assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
1132 assert!(host_for_assert.saw.lock().unwrap().is_empty());
1133 crate::host_free_string(out);
1134 }
1135
1136 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1141 async fn trampoline_rejects_stale_bridge_with_invalidate() {
1142 let host = Arc::new(MockHost {
1143 prompt: String::new(),
1144 saw: Mutex::new(Vec::new()),
1145 });
1146 let host_for_assert = Arc::clone(&host);
1147 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1148 let runtime = tokio::runtime::Handle::current();
1149 let bridge = ActionBridge::new(runtime, host_dyn);
1150 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1151
1152 bridge.invalidate();
1154 assert!(!bridge.is_active());
1155
1156 let args_ref = StbStringRef::from_str("{}");
1157 let mut out = StbString::empty();
1158 let rc = trampoline_runtime_action(
1159 RuntimeActionId::GetSystemPrompt.into(),
1160 args_ref,
1161 &mut out as *mut StbString,
1162 user_data,
1163 );
1164 assert_eq!(rc, 1, "stale bridge ⇒ error return code");
1166 let json_text = out.to_string_lossy();
1167 assert!(
1168 json_text.contains("stale"),
1169 "stale-bridge error payload: {json_text}"
1170 );
1171 crate::host_free_string(out);
1172 assert!(
1173 host_for_assert.saw.lock().unwrap().is_empty(),
1174 "host dispatch must NOT run on a stale bridge"
1175 );
1176 }
1177
1178 #[tokio::test]
1179 async fn trampoline_rejects_unknown_numeric_id_without_dispatch() {
1180 let host = Arc::new(MockHost {
1181 prompt: String::new(),
1182 saw: Mutex::new(Vec::new()),
1183 });
1184 let host_for_assert = Arc::clone(&host);
1185 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1186 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1187 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1188
1189 let mut out = StbString::empty();
1190 let rc = trampoline_runtime_action(
1191 0xFFFF_FFFE,
1192 StbStringRef::from_str("{}"),
1193 &mut out,
1194 user_data,
1195 );
1196
1197 assert_eq!(rc, 2, "unknown action id must be a protocol error");
1198 let payload: serde_json::Value =
1199 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1200 assert_eq!(payload["error"], "unknown runtime action id 4294967294");
1201 crate::host_free_string(out);
1202 assert!(
1203 host_for_assert.saw.lock().unwrap().is_empty(),
1204 "unknown action id must not reach host dispatch"
1205 );
1206 }
1207
1208 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1209 async fn v2_trampoline_accepts_new_action() {
1210 let host = Arc::new(MockHost {
1211 prompt: "v2".to_string(),
1212 saw: Mutex::new(Vec::new()),
1213 });
1214 let host_for_assert = Arc::clone(&host);
1215 let host_dyn: Arc<dyn RuntimeActionHost> = host;
1216 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host_dyn);
1217 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1218
1219 let mut v2_out = StbString::empty();
1220 assert_eq!(
1221 trampoline_runtime_action(
1222 RuntimeActionId::GetCliFlag.into(),
1223 StbStringRef::from_str(r#"{"name":"flag"}"#),
1224 &mut v2_out,
1225 user_data,
1226 ),
1227 0
1228 );
1229 crate::host_free_string(v2_out);
1230
1231 assert_eq!(
1232 *host_for_assert.saw.lock().unwrap(),
1233 vec![RuntimeActionId::GetCliFlag]
1234 );
1235 }
1236
1237 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1242 async fn reload_mailbox_signals_installed_receiver() {
1243 let mailbox = ReloadMailbox::new();
1244 assert!(matches!(mailbox.signal(), Err(())));
1246
1247 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1248 mailbox.install(tx);
1249 let cb = reload_callback_from_mailbox(mailbox.clone());
1250 cb().await;
1251 assert_eq!(
1252 rx.recv().await,
1253 Some(()),
1254 "installed receiver saw the signal"
1255 );
1256 mailbox.clear();
1257 }
1258
1259 fn open_args(id: &str, tool_call: &str) -> serde_json::Value {
1262 serde_json::json!({
1263 "op": "open",
1264 "requestId": id,
1265 "toolCallId": tool_call,
1266 "ui": {"kind": "selector", "questions": [
1267 {"id": "q1", "question": "Which target?", "options": ["Linux"]}
1268 ]},
1269 })
1270 }
1271
1272 #[test]
1275 fn ui_dialog_open_requires_an_attached_ui() {
1276 let mailbox = UiDialogMailbox::new();
1277 let error = mailbox.handle(open_args("r1", "c1")).unwrap_err();
1278 assert!(error.contains("interactive UI"), "got: {error}");
1279 }
1280
1281 #[test]
1282 fn ui_dialog_open_poll_answer_round_trip() {
1283 let mailbox = UiDialogMailbox::new();
1284 mailbox.attach();
1285
1286 let opened = mailbox.handle(open_args("r1", "c1")).unwrap();
1287 assert_eq!(opened["status"], "pending");
1288 assert_eq!(opened["toolCallId"], "c1");
1289
1290 let request = mailbox.take_pending().expect("request visible to TUI");
1291 assert_eq!(request.request_id, "r1");
1292 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
1293 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1295 assert_eq!(mailbox.poll("r1").unwrap()["status"], "pending");
1296
1297 mailbox
1298 .respond("r1", serde_json::json!("7890"))
1299 .expect("respond");
1300 let answered = mailbox.poll("r1").unwrap();
1301 assert_eq!(answered["status"], "answered");
1302 assert_eq!(answered["answer"], "7890");
1303 assert!(mailbox.poll("r1").is_err());
1305 }
1306
1307 #[test]
1308 fn ui_dialog_cancel_is_reported_once() {
1309 let mailbox = UiDialogMailbox::new();
1310 mailbox.attach();
1311 mailbox.handle(open_args("r1", "c1")).unwrap();
1312 mailbox.cancel("r1").unwrap();
1313 let cancelled = mailbox.poll("r1").unwrap();
1314 assert_eq!(cancelled["status"], "cancelled");
1315 assert!(mailbox.poll("r1").is_err());
1316 }
1317
1318 #[test]
1319 fn ui_dialog_concurrent_requests_do_not_cross_answers() {
1320 let mailbox = UiDialogMailbox::new();
1321 mailbox.attach();
1322 mailbox.handle(open_args("r1", "c1")).unwrap();
1323 mailbox.handle(open_args("r2", "c2")).unwrap();
1324
1325 let first = mailbox.take_pending().unwrap().request_id;
1326 let second = mailbox.take_pending().unwrap().request_id;
1327 assert_ne!(first, second);
1328
1329 mailbox
1331 .respond(&second, serde_json::json!("second"))
1332 .unwrap();
1333 assert_eq!(mailbox.poll(&first).unwrap()["status"], "pending");
1334 let second_answer = mailbox.poll(&second).unwrap();
1335 assert_eq!(second_answer["answer"], "second");
1336
1337 mailbox.respond(&first, serde_json::json!("first")).unwrap();
1338 assert_eq!(mailbox.poll(&first).unwrap()["answer"], "first");
1339 }
1340
1341 #[test]
1342 fn ui_dialog_detach_cancels_outstanding() {
1343 let mailbox = UiDialogMailbox::new();
1344 mailbox.attach();
1345 mailbox.handle(open_args("r1", "c1")).unwrap();
1346 mailbox.detach();
1347 assert!(!mailbox.is_attached());
1348 assert_eq!(mailbox.pending_len(), 0);
1349 assert!(mailbox.handle(open_args("r2", "c2")).is_err());
1351 assert_eq!(mailbox.poll("r1").unwrap()["status"], "cancelled");
1352 }
1353
1354 #[test]
1355 fn ui_dialog_duplicate_open_does_not_reenqueue() {
1356 let mailbox = UiDialogMailbox::new();
1357 mailbox.attach();
1358 mailbox.handle(open_args("r1", "c1")).unwrap();
1359 let again = mailbox.handle(open_args("r1", "c1")).unwrap();
1360 assert_eq!(again["status"], "pending");
1361 assert_eq!(mailbox.pending_len(), 1);
1362 mailbox.take_pending().unwrap();
1363 assert!(mailbox.take_pending().is_none());
1364 }
1365
1366 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1370 async fn v2_dispatch_routes_ui_dialog_action_17() {
1371 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1372 prompt: String::new(),
1373 saw: Mutex::new(Vec::new()),
1374 });
1375 let bridge = ActionBridge::with_ui_dialog(
1376 tokio::runtime::Handle::current(),
1377 host,
1378 UiDialogMailbox::new(),
1379 );
1380 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1381
1382 let mut out = StbString::empty();
1383 let rc = trampoline_runtime_action(
1384 17,
1385 StbStringRef::from_str(r#"{"op":"open","requestId":"r1","ui":{}}"#),
1386 &mut out,
1387 user_data,
1388 );
1389 assert_eq!(rc, 1);
1390 let payload: serde_json::Value =
1391 serde_json::from_str(&out.to_string_lossy()).expect("structured error JSON");
1392 assert!(
1393 payload["error"]
1394 .as_str()
1395 .is_some_and(|error| error.contains("interactive UI")),
1396 "unexpected payload: {payload}"
1397 );
1398 crate::host_free_string(out);
1399
1400 let mut out = StbString::empty();
1403 assert_eq!(
1404 trampoline_runtime_action(20, StbStringRef::from_str("{}"), &mut out, user_data),
1405 2
1406 );
1407 crate::host_free_string(out);
1408 }
1409
1410 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1413 async fn v2_dispatch_routes_set_status_action_18() {
1414 let host: Arc<dyn RuntimeActionHost> = Arc::new(MockHost {
1415 prompt: String::new(),
1416 saw: Mutex::new(Vec::new()),
1417 });
1418 let status = crate::status::ExtensionStatusMailbox::new();
1419 let bridge = ActionBridge::with_reload_and_ui(
1420 tokio::runtime::Handle::current(),
1421 host,
1422 Arc::new(|| Box::pin(async {})),
1423 UiDialogMailbox::new(),
1424 status.clone(),
1425 crate::editor_text::EditorTextMailbox::new(),
1426 );
1427 let user_data = Arc::as_ptr(&bridge) as *mut c_void;
1428
1429 let mut out = StbString::empty();
1430 let rc = trampoline_runtime_action(
1431 18,
1432 StbStringRef::from_str(r#"{"key":"langfuse","value":"langfuse ✓ (trace sent)"}"#),
1433 &mut out,
1434 user_data,
1435 );
1436 assert_eq!(rc, 0);
1437 assert_eq!(status.text(), "langfuse ✓ (trace sent)");
1438 crate::host_free_string(out);
1439
1440 let mut out = StbString::empty();
1442 assert_eq!(
1443 trampoline_runtime_action(
1444 18,
1445 StbStringRef::from_str(r#"{"key":"langfuse","value":""}"#),
1446 &mut out,
1447 user_data
1448 ),
1449 0
1450 );
1451 assert_eq!(status.text(), "");
1452 crate::host_free_string(out);
1453
1454 let mut out = StbString::empty();
1456 assert_eq!(
1457 trampoline_runtime_action(18, StbStringRef::from_str("{}"), &mut out, user_data),
1458 1
1459 );
1460 crate::host_free_string(out);
1461 }
1462}