1use std::ffi::c_void;
14use std::panic::{catch_unwind, AssertUnwindSafe};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::{Arc, RwLock};
17
18use async_trait::async_trait;
19use rpi_agent::agent_tool::AgentTool;
20use rpi_agent::error::AgentError;
21use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolExecutionMode, ToolResultPartial};
22use rpi_ai::types::Tool;
23use rpi_plugin_sdk::{
24 FreeStringFn, StbString, StbStringRef, StepHandle, StepResultTag, ToolCancelFn, ToolDestroyFn,
25 ToolExecuteFn, ToolPollFn,
26};
27use serde_json::Value;
28use tokio::sync::{mpsc, oneshot};
29use tokio_util::sync::CancellationToken;
30
31use crate::host_free_string;
32use crate::loader::PluginKeepalive;
33
34#[derive(Clone, Copy)]
48pub struct PluginToolHandle {
49 pub(crate) execute_fn: ToolExecuteFn,
50 pub(crate) poll_fn: ToolPollFn,
51 pub(crate) cancel_fn: ToolCancelFn,
52 pub(crate) destroy_fn: ToolDestroyFn,
53 pub(crate) plugin_free_string: FreeStringFn,
54}
55
56#[allow(dead_code)]
64fn result_to_json(result: &AgentToolResult) -> String {
65 let mut txt = String::new();
66 txt.push('{');
67 txt.push_str("\"content\":[");
68 for (i, c) in result.content.iter().enumerate() {
69 if i > 0 {
70 txt.push(',');
71 }
72 match c {
73 TextContentOrImage::Text(t) => {
74 txt.push_str(
75 &serde_json::to_string(&serde_json::json!({ "type": "text", "text": t.text }))
76 .unwrap_or_else(|_| "\"\"".into()),
77 );
78 }
79 TextContentOrImage::Image(img) => {
80 txt.push_str(
81 &serde_json::to_string(&serde_json::json!({
82 "type": "image",
83 "data": img.data,
84 "mimeType": img.mime_type,
85 }))
86 .unwrap_or_else(|_| "\"\"".into()),
87 );
88 }
89 }
90 }
91 txt.push(']');
92 txt.push_str(",\"details\":");
93 txt.push_str(&serde_json::to_string(&result.details).unwrap_or_else(|_| "null".into()));
94 txt.push_str(",\"terminate\":");
95 txt.push_str(if result.terminate { "true" } else { "false" });
96 txt.push_str(",\"addedToolNames\":");
97 txt.push_str(&serde_json::to_string(&result.added_tool_names).unwrap_or_else(|_| "[]".into()));
98 txt.push('}');
102 txt
103}
104
105fn stb_to_result(s: &StbString) -> AgentToolResult {
109 let text = s.to_string_lossy();
110 let val: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
111 let mut result = AgentToolResult::default();
112 if let Some(obj) = val.as_object() {
113 if let Some(content) = obj.get("content").and_then(|v| v.as_array()) {
114 for block in content {
115 let kind = block.get("type").and_then(|v| v.as_str()).unwrap_or("text");
116 match kind {
117 "image" => {
118 let data = block
119 .get("data")
120 .and_then(|v| v.as_str())
121 .unwrap_or("")
122 .to_string();
123 let mime = block
124 .get("mimeType")
125 .or_else(|| block.get("mime_type"))
126 .and_then(|v| v.as_str())
127 .unwrap_or("image/png")
128 .to_string();
129 result.content.push(TextContentOrImage::Image(
130 rpi_ai::types::ImageContent {
131 kind: rpi_ai::types::ImageContentType,
132 data,
133 mime_type: mime,
134 },
135 ));
136 }
137 _ => {
138 let t = block
139 .get("text")
140 .and_then(|v| v.as_str())
141 .unwrap_or("")
142 .to_string();
143 result.content.push(TextContentOrImage::text(t));
144 }
145 }
146 }
147 }
148 if let Some(details) = obj.get("details") {
149 result.details = details.clone();
150 }
151 if let Some(terms) = obj.get("terminate").and_then(|v| v.as_bool()) {
152 result.terminate = terms;
153 }
154 if let Some(arr) = obj
155 .get("addedToolNames")
156 .or_else(|| obj.get("added_tool_names"))
157 .and_then(|v| v.as_array())
158 {
159 result.added_tool_names = arr
160 .iter()
161 .filter_map(|v| v.as_str().map(String::from))
162 .collect();
163 }
164 if let Some(usage) = obj.get("usage") {
165 if let Ok(u) = serde_json::from_value::<rpi_ai::types::Usage>(usage.clone()) {
166 result.usage = Some(u);
167 }
168 }
169 }
170 result
171}
172
173#[derive(Clone, Debug, Default)]
196pub struct ToolCallContext {
197 inner: Arc<RwLock<ToolCallContextState>>,
198}
199
200#[derive(Debug, Default)]
201struct ToolCallContextState {
202 cwd: Option<String>,
203 session_id: Option<String>,
204}
205
206impl ToolCallContext {
207 pub fn new(cwd: impl Into<String>) -> Self {
210 let cwd = cwd.into();
211 Self {
212 inner: Arc::new(RwLock::new(ToolCallContextState {
213 cwd: (!cwd.is_empty()).then_some(cwd),
214 session_id: None,
215 })),
216 }
217 }
218
219 pub fn set_session_id(&self, session_id: impl Into<String>) {
223 if let Ok(mut state) = self.inner.write() {
224 state.session_id = Some(session_id.into());
225 }
226 }
227
228 pub fn cwd(&self) -> Option<String> {
230 self.inner.read().ok().and_then(|s| s.cwd.clone())
231 }
232
233 pub fn session_id(&self) -> Option<String> {
235 self.inner.read().ok().and_then(|s| s.session_id.clone())
236 }
237
238 pub fn to_json(&self) -> Option<Value> {
242 let state = self.inner.read().ok()?;
243 if state.cwd.is_none() && state.session_id.is_none() {
244 return None;
245 }
246 let mut object = serde_json::Map::new();
247 if let Some(cwd) = &state.cwd {
248 object.insert("cwd".to_string(), Value::String(cwd.clone()));
249 }
250 if let Some(session_id) = &state.session_id {
251 object.insert("sessionId".to_string(), Value::String(session_id.clone()));
252 }
253 Some(Value::Object(object))
254 }
255}
256
257fn inject_tool_context(params: Value, context: &ToolCallContext) -> Value {
269 let Some(context_json) = context.to_json() else {
270 return params;
271 };
272 match params {
273 Value::Object(mut object) => {
274 object.insert(rpi_plugin_sdk::HOST_CONTEXT_KEY.to_string(), context_json);
275 Value::Object(object)
276 }
277 Value::Null => {
278 let mut object = serde_json::Map::new();
279 object.insert(rpi_plugin_sdk::HOST_CONTEXT_KEY.to_string(), context_json);
280 Value::Object(object)
281 }
282 other => other,
283 }
284}
285
286pub struct PluginToolAdapter {
301 schema: Tool,
302 label: String,
303 handle: PluginToolHandle,
304 context: ToolCallContext,
309 #[allow(dead_code)]
315 keepalive: Arc<PluginKeepalive>,
316}
317
318impl PluginToolAdapter {
319 pub fn new(schema: Tool, handle: PluginToolHandle, keepalive: Arc<PluginKeepalive>) -> Self {
323 let label = schema.name.clone();
324 Self {
325 schema,
326 label,
327 handle,
328 context: ToolCallContext::default(),
329 keepalive,
330 }
331 }
332
333 pub fn with_context(mut self, context: ToolCallContext) -> Self {
337 self.context = context;
338 self
339 }
340}
341
342extern "C" fn partial_cb_trampoline(partial: StbString, user_data: *mut c_void) {
350 let outcome = catch_unwind(AssertUnwindSafe(|| {
351 if user_data.is_null() {
352 host_free_string(partial);
355 return;
356 }
357 let sender = unsafe { &*(user_data as *const mpsc::UnboundedSender<AgentToolResult>) };
360 let result = stb_to_result(&partial);
361 host_free_string(partial);
365 let _ = sender.send(result);
366 }));
367 if outcome.is_err() {
368 tracing::error!("plugin partial callback panicked — dropped (host continues)");
373 }
374}
375
376#[async_trait]
377impl AgentTool for PluginToolAdapter {
378 fn schema(&self) -> &Tool {
379 &self.schema
380 }
381
382 fn label(&self) -> &str {
383 &self.label
384 }
385
386 fn execution_mode(&self) -> ToolExecutionMode {
387 ToolExecutionMode::Parallel
390 }
391
392 async fn execute(
393 &self,
394 tool_call_id: &str,
395 params: serde_json::Value,
396 signal: CancellationToken,
397 on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
398 ) -> Result<AgentToolResult, AgentError> {
399 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
402 AgentError::State(format!(
403 "plugin tool '{}' executed off-runtime: {e}",
404 self.schema.name
405 ))
406 })?;
407
408 let (partial_tx, mut partial_rx) = mpsc::unbounded_channel::<AgentToolResult>();
410 let (done_tx, done_rx) = oneshot::channel::<Result<AgentToolResult, AgentError>>();
411
412 let cancel_flag = Arc::new(AtomicBool::new(false));
416
417 let params = inject_tool_context(params, &self.context);
423 let params_json = serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string());
424 let params_stb = StbString::from_string(params_json);
425 let id_ref = StbStringRef::from_str(tool_call_id);
426 let plugin_free = self.handle.plugin_free_string;
427
428 let execute_fn = self.handle.execute_fn;
429 let poll_fn = self.handle.poll_fn;
430 let cancel_fn = self.handle.cancel_fn;
431 let destroy_fn = self.handle.destroy_fn;
432
433 let schema_name = self.schema.name.clone();
434 let schema_name_for_error = schema_name.clone();
435
436 let cancel_flag_drive = Arc::clone(&cancel_flag);
442 let sender_for_cb = partial_tx.clone();
443 runtime.spawn_blocking(move || {
444 let step_handle: StepHandle = {
448 let outcome = catch_unwind(AssertUnwindSafe(|| {
449 (execute_fn)(id_ref, params_stb, Some(host_free_string))
450 }));
451 match outcome {
452 Ok(h) if !h.is_null() => h,
453 Ok(_) => {
454 let _ = done_tx.send(Err(AgentError::Tool(format!(
456 "plugin execute returned null handle for '{schema_name}'"
457 ))));
458 return;
459 }
460 Err(_) => {
461 tracing::error!("plugin execute panicked — reporting tool error");
466 let _ = done_tx.send(Err(AgentError::Tool(format!(
467 "plugin tool '{schema_name}' panicked during execute"
468 ))));
469 return;
470 }
471 }
472 };
473
474 let sender_ptr = &sender_for_cb as *const _ as *mut c_void;
477
478 let terminal: Result<AgentToolResult, AgentError> = loop {
480 if cancel_flag_drive.load(Ordering::SeqCst) {
481 let _ = catch_unwind(AssertUnwindSafe(|| (cancel_fn)(step_handle)));
483 break Err(AgentError::Tool("plugin tool cancelled".into()));
484 }
485 let step_result = match catch_unwind(AssertUnwindSafe(|| {
486 (poll_fn)(step_handle, Some(partial_cb_trampoline), sender_ptr)
487 })) {
488 Ok(r) => r,
489 Err(_) => {
490 tracing::error!("plugin poll panicked — reporting tool error");
491 break Err(AgentError::Tool(format!(
492 "plugin tool '{schema_name}' panicked during poll"
493 )));
494 }
495 };
496 match step_result.tag {
497 StepResultTag::Pending => {
498 let progress = unsafe { step_result.pending_payload().progress };
500 if !progress.is_empty() {
501 let pr = stb_to_result(&progress);
504 (plugin_free)(progress);
507 let _ = partial_tx.send(pr);
508 }
509 continue;
510 }
511 StepResultTag::Done => {
512 let done = unsafe { step_result.done_payload().result };
514 let result = stb_to_result(&done);
515 (plugin_free)(done);
516 break Ok(result);
517 }
518 StepResultTag::Err => {
519 let msg = unsafe { step_result.err_payload().message };
521 let message = msg.to_string_lossy();
522 (plugin_free)(msg);
523 break Err(AgentError::Tool(message));
524 }
525 }
526 };
527
528 let _ = catch_unwind(AssertUnwindSafe(|| (destroy_fn)(step_handle)));
530 let _ = done_tx.send(terminal);
531 });
532
533 let schema_name_err = schema_name_for_error.clone();
540 tokio::pin!(done_rx);
541 let mut cancelled = std::pin::pin!(signal.cancelled());
542 let result: Result<AgentToolResult, AgentError> = loop {
543 tokio::select! {
544 done = &mut done_rx => {
545 while let Ok(p) = partial_rx.try_recv() { on_update(p); }
546 break match done {
547 Ok(Ok(r)) => Ok(r),
548 Ok(Err(e)) => Err(e),
549 Err(_) => Err(AgentError::State(format!(
550 "plugin tool '{schema_name_err}' driver dropped done_tx"
551 ))),
552 };
553 }
554 _ = &mut cancelled => {
555 let already = cancel_flag.swap(true, Ordering::SeqCst);
558 if !already {
559 while let Ok(p) = partial_rx.try_recv() { on_update(p); }
560 }
561 tokio::task::yield_now().await;
563 }
564 }
565 };
566
567 while let Ok(p) = partial_rx.try_recv() {
569 on_update(p);
570 }
571
572 result
573 }
574}
575
576#[cfg(test)]
581mod tests {
582 use super::*;
583 use rpi_plugin_sdk::{StepResult, ToolPartialCb};
584 use serde_json::json;
585 use std::sync::atomic::{AtomicUsize, Ordering};
586 use std::sync::Mutex;
587
588 static TEST_LOCK: Mutex<()> = Mutex::new(());
595
596 static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
597 static CANCEL_COUNT: AtomicUsize = AtomicUsize::new(0);
598
599 static LAST_PARAMS: Mutex<Option<String>> = Mutex::new(None);
605
606 fn take_last_params() -> Value {
607 let raw = LAST_PARAMS
608 .lock()
609 .unwrap()
610 .take()
611 .expect("stub_execute should have recorded params");
612 serde_json::from_str(&raw).expect("params should be JSON")
613 }
614
615 struct DriveState {
616 cancelled: Arc<AtomicBool>,
617 polls: usize,
618 done_at: usize,
619 }
620
621 extern "C" fn stub_execute(
622 _id: StbStringRef,
623 params: StbString,
624 _free: Option<FreeStringFn>,
625 ) -> StepHandle {
626 *LAST_PARAMS.lock().unwrap() = Some(params.to_string_lossy());
627 let state = Box::new(DriveState {
628 cancelled: Arc::new(AtomicBool::new(false)),
629 polls: 0,
630 done_at: 3,
631 });
632 Box::into_raw(state) as StepHandle
633 }
634
635 extern "C" fn stub_poll(
636 h: StepHandle,
637 _cb: Option<ToolPartialCb>,
638 _ud: *mut c_void,
639 ) -> StepResult {
640 let state = unsafe { &mut *(h as *mut DriveState) };
641 state.polls += 1;
642 if state.cancelled.load(Ordering::SeqCst) {
643 return StepResult::err(StbString::from_string("cancelled".into()));
644 }
645 if state.polls >= state.done_at {
646 let result_json = StbString::from_string(
647 r#"{"content":[{"type":"text","text":"echo: hello"}]}"#.to_string(),
648 );
649 StepResult::done(result_json)
650 } else {
651 StepResult::pending(StbString::from_string(
652 r#"{"content":[{"type":"text","text":"..."}]}"#.to_string(),
653 ))
654 }
655 }
656
657 extern "C" fn stub_cancel(h: StepHandle) {
658 CANCEL_COUNT.fetch_add(1, Ordering::SeqCst);
659 let state = unsafe { &*(h as *const DriveState) };
660 state.cancelled.store(true, Ordering::SeqCst);
661 }
662
663 extern "C" fn stub_destroy(h: StepHandle) {
664 DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
665 if h.is_null() {
666 return;
667 }
668 unsafe {
669 let _ = Box::from_raw(h as *mut DriveState);
670 }
671 }
672
673 extern "C" fn stub_free(s: StbString) {
674 if s.is_empty() || s.ptr.is_null() {
675 return;
676 }
677 unsafe {
678 let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
679 let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
680 }
681 }
682
683 fn stub_handle() -> PluginToolHandle {
684 PluginToolHandle {
685 execute_fn: stub_execute,
686 poll_fn: stub_poll,
687 cancel_fn: stub_cancel,
688 destroy_fn: stub_destroy,
689 plugin_free_string: stub_free,
690 }
691 }
692
693 fn echo_adapter() -> PluginToolAdapter {
694 let tool = Tool {
695 name: "echo".to_string(),
696 description: "echoes".to_string(),
697 parameters: rpi_ai::types::Schema::new(serde_json::json!({})),
698 constrained_sampling: None,
699 };
700 PluginToolAdapter::new(tool, stub_handle(), PluginKeepalive::empty())
701 }
702
703 fn reset_counters() {
704 DESTROY_COUNT.store(0, Ordering::SeqCst);
705 CANCEL_COUNT.store(0, Ordering::SeqCst);
706 }
707
708 #[tokio::test]
709 async fn adapter_drives_to_done_and_destroys_once() {
710 let _guard = TEST_LOCK.lock().unwrap();
711 reset_counters();
712 let adapter = echo_adapter();
713 let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
714 let signal = CancellationToken::new();
715 let result = adapter
716 .execute("call_1", serde_json::json!({}), signal, on_update)
717 .await
718 .expect("drive should succeed");
719 assert_eq!(result.content.len(), 1);
720 assert_eq!(
721 DESTROY_COUNT.load(Ordering::SeqCst),
722 1,
723 "destroy exactly once"
724 );
725 assert_eq!(
726 CANCEL_COUNT.load(Ordering::SeqCst),
727 0,
728 "no cancel in happy path"
729 );
730 }
731
732 #[tokio::test]
733 async fn adapter_forwards_partials_to_on_update() {
734 let _guard = TEST_LOCK.lock().unwrap();
735 reset_counters();
736 let adapter = echo_adapter();
737 let seen = Arc::new(Mutex::new(Vec::<String>::new()));
738 let seen_clone = Arc::clone(&seen);
739 let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(move |p| {
740 if let Some(t) = p.content.first().and_then(|c| match c {
741 TextContentOrImage::Text(t) => Some(t.text.clone()),
742 _ => None,
743 }) {
744 seen_clone.lock().unwrap().push(t);
745 }
746 });
747 let signal = CancellationToken::new();
748 let _ = adapter
749 .execute("call_2", serde_json::json!({}), signal, on_update)
750 .await
751 .expect("ok");
752 let partials = seen.lock().unwrap().clone();
753 assert!(partials.iter().any(|t| t == "..."), "got {:?}", partials);
754 assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1);
755 }
756
757 #[tokio::test(flavor = "current_thread")]
758 async fn adapter_cancel_observed_no_uaf_no_leak() {
759 let _guard = TEST_LOCK.lock().unwrap();
760 reset_counters();
761 struct SlowState {
762 cancelled: Arc<AtomicBool>,
763 polls: usize,
764 }
765 extern "C" fn slow_execute(
766 _: StbStringRef,
767 _: StbString,
768 _: Option<FreeStringFn>,
769 ) -> StepHandle {
770 Box::into_raw(Box::new(SlowState {
771 cancelled: Arc::new(AtomicBool::new(false)),
772 polls: 0,
773 })) as StepHandle
774 }
775 extern "C" fn slow_poll(
776 h: StepHandle,
777 _: Option<ToolPartialCb>,
778 _: *mut c_void,
779 ) -> StepResult {
780 let s = unsafe { &mut *(h as *mut SlowState) };
781 s.polls += 1;
782 if s.cancelled.load(Ordering::SeqCst) {
783 return StepResult::err(StbString::from_string("cancelled".into()));
784 }
785 std::thread::sleep(std::time::Duration::from_millis(5));
786 StepResult::pending(StbString::empty())
787 }
788 extern "C" fn slow_cancel(h: StepHandle) {
789 CANCEL_COUNT.fetch_add(1, Ordering::SeqCst);
790 unsafe {
791 (*(h as *mut SlowState))
792 .cancelled
793 .store(true, Ordering::SeqCst);
794 }
795 }
796 extern "C" fn slow_destroy(h: StepHandle) {
797 DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
798 if !h.is_null() {
799 unsafe {
800 let _ = Box::from_raw(h as *mut SlowState);
801 }
802 }
803 }
804 let tool = Tool {
805 name: "slow".to_string(),
806 description: "slow".to_string(),
807 parameters: rpi_ai::types::Schema::new(serde_json::json!({})),
808 constrained_sampling: None,
809 };
810 let handle = PluginToolHandle {
811 execute_fn: slow_execute,
812 poll_fn: slow_poll,
813 cancel_fn: slow_cancel,
814 destroy_fn: slow_destroy,
815 plugin_free_string: stub_free,
816 };
817 let adapter = PluginToolAdapter::new(tool, handle, PluginKeepalive::empty());
818 let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
819 let signal = CancellationToken::new();
820 let signal_clone = signal.clone();
821 tokio::spawn(async move {
822 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
823 signal_clone.cancel();
824 });
825 let _ = adapter
826 .execute("call_3", serde_json::json!({}), signal, on_update)
827 .await;
828 assert_eq!(
830 DESTROY_COUNT.load(Ordering::SeqCst),
831 1,
832 "destroy once on cancel"
833 );
834 }
835
836 #[test]
839 fn injected_context_carries_cwd_and_session_only_when_known() {
840 assert!(ToolCallContext::default().to_json().is_none());
843 assert!(ToolCallContext::new("").to_json().is_none());
844
845 let context = ToolCallContext::new("D:\\proj");
846 let json = context.to_json().expect("cwd alone is enough to inject");
847 assert_eq!(json["cwd"], "D:\\proj");
848 assert!(json.get("sessionId").is_none(), "not yet recorded");
849
850 context.set_session_id("sess-1");
851 let json = context.to_json().unwrap();
852 assert_eq!(json["cwd"], "D:\\proj");
853 assert_eq!(json["sessionId"], "sess-1");
854 }
855
856 #[test]
857 fn injection_targets_objects_and_leaves_other_shapes_alone() {
858 let context = ToolCallContext::new("/proj");
859 context.set_session_id("s");
860
861 let merged = inject_tool_context(json!({"action": "list"}), &context);
862 assert_eq!(merged["action"], "list", "model arguments survive");
863 assert_eq!(merged["__rpi"]["sessionId"], "s");
864
865 let merged = inject_tool_context(Value::Null, &context);
868 assert_eq!(merged["__rpi"]["cwd"], "/proj");
869
870 let array = json!([1, 2]);
872 assert_eq!(inject_tool_context(array.clone(), &context), array);
873 assert_eq!(inject_tool_context(json!("raw"), &context), json!("raw"));
874 }
875
876 #[test]
877 fn host_context_overrides_a_forged_one() {
878 let context = ToolCallContext::new("/real");
879 context.set_session_id("real-session");
880 let merged = inject_tool_context(
881 json!({"action": "list", "__rpi": {"cwd": "/forged", "sessionId": "forged"}}),
882 &context,
883 );
884 assert_eq!(merged["__rpi"]["cwd"], "/real");
885 assert_eq!(merged["__rpi"]["sessionId"], "real-session");
886 }
887
888 #[test]
889 fn injection_is_a_no_op_without_a_context() {
890 let params = json!({"action": "list"});
891 let merged = inject_tool_context(params.clone(), &ToolCallContext::default());
892 assert_eq!(merged, params, "an unattached adapter behaves as before");
893 }
894
895 #[tokio::test]
896 async fn plugin_receives_the_host_context_across_the_ffi_boundary() {
897 let _guard = TEST_LOCK.lock().unwrap();
898 reset_counters();
899 *LAST_PARAMS.lock().unwrap() = None;
900
901 let context = ToolCallContext::new("D:\\proj");
902 context.set_session_id("sess-42");
903 let adapter = echo_adapter().with_context(context);
904 let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
905 adapter
906 .execute(
907 "call_ctx",
908 json!({"action": "list"}),
909 CancellationToken::new(),
910 on_update,
911 )
912 .await
913 .expect("drive should succeed");
914
915 let seen = take_last_params();
916 assert_eq!(seen["action"], "list");
917 assert_eq!(seen["__rpi"]["cwd"], "D:\\proj");
918 assert_eq!(seen["__rpi"]["sessionId"], "sess-42");
919 }
920}