Skip to main content

rpi_extensions/
tool.rs

1//! [`PluginToolHandle`] — the plugin's 4-function lifecycle bundle held per
2//! registered tool — and [`PluginToolAdapter`], the `AgentTool` impl that drives
3//! it across the async/FFI boundary via the corrected spawn_blocking bridge.
4//!
5//! See the crate-level docs for the load-bearing soundness rationale. The short
6//! version: a plugin tool is driven by **four** plugin fns (`execute`→handle,
7//! `poll`, `cancel`, `destroy`). The adapter spawns a **blocking** driver that
8//! loops `poll`, forwards `Pending` partials through an mpsc, sends the terminal
9//! result through a oneshot, and calls `destroy` **exactly once**. Cancel sets
10//! an `AtomicBool` the driver observes; the async side keeps awaiting oneshot
11//! (never drops the driver). `cancel` ≠ `destroy`.
12
13use 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// ---------------------------------------------------------------------------
35// PluginToolHandle — the 4-fn bundle + the plugin's free_string
36// ---------------------------------------------------------------------------
37
38/// The plugin's per-tool lifecycle bundle the host holds after a successful
39/// `register_tool`. All fields are fn pointers (Copy), so the handle is `Copy`:
40/// cloning duplicates the pointers, not any allocation. A registered tool is
41/// driven by at most one [`PluginToolAdapter`] at a time, but the handle is
42/// copied through the registry snapshot path, hence `Copy`.
43///
44/// `plugin_free_string` is the fn the **plugin** exports to free the
45/// [`StbString`]s it *produces* (schema strings, terminal result, pending
46/// progress). The host calls it for each such string it receives.
47#[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// ---------------------------------------------------------------------------
57// JSON ⇄ AgentToolResult helpers (host side)
58// ---------------------------------------------------------------------------
59
60/// Serialize an `AgentToolResult` to a JSON string. Used for partials the host
61/// hands to the plugin's partial callback (host-produced → plugin frees via
62/// host `free_string`) and is also the shape the plugin returns for `Done`.
63#[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    // usage omitted from the wire shape (Option<Usage> is not in every plugin's
99    // contract; the model-visible content/details are what matter). Documented
100    // v1 limit: usage does not cross to the plugin.
101    txt.push('}');
102    txt
103}
104
105/// Parse a plugin-produced JSON `AgentToolResult` back into the native struct.
106/// Lenient: missing fields default. The caller frees the input `StbString` via
107/// the plugin's `free_string` (plugin produced it).
108fn 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// ---------------------------------------------------------------------------
174// ToolCallContext — the per-session context a plugin tool is handed
175// ---------------------------------------------------------------------------
176
177/// Per-session context the host injects into plugin tool arguments under
178/// [`rpi_plugin_sdk::HOST_CONTEXT_KEY`].
179///
180/// A built-in tool gets an `ExecutionToolContext` from the harness and can
181/// resolve the working directory from it; a plugin tool has no such channel.
182/// Without this, a plugin that needs project (or session) identity has to ask
183/// the model for it — and the model does not know the session id, while any
184/// `cwd` it guesses is a silent way to split one store into two.
185///
186/// It is filled in two steps because the CLI assembles the tool set *before* the
187/// session it belongs to exists: [`ToolCallContext::new`] carries the `cwd`, and
188/// [`ToolCallContext::set_session_id`] completes it once the session does.
189/// Every adapter for one session shares one instance (`Arc` inside), so the late
190/// write is visible to all of them — including across an extension reload, which
191/// rebuilds the adapters from the same context.
192///
193/// Unknown fields are absent, not empty strings: a consumer should prefer a
194/// project-scoped fallback when [`ToolCallContext::session_id`] is `None`.
195#[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    /// A context that knows the project directory but not yet the session id.
208    /// An empty `cwd` is treated as unknown.
209    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    /// Record the session id once the session exists. Safe to call again (a
220    /// reload re-derives it); a poisoned lock is ignored rather than panicking
221    /// inside a tool call.
222    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    /// The project directory the session was launched in, when known.
229    pub fn cwd(&self) -> Option<String> {
230        self.inner.read().ok().and_then(|s| s.cwd.clone())
231    }
232
233    /// The session id, when the session has one and the host has recorded it.
234    pub fn session_id(&self) -> Option<String> {
235        self.inner.read().ok().and_then(|s| s.session_id.clone())
236    }
237
238    /// The object to inject, or `None` when nothing is known yet — in which
239    /// case arguments are passed through untouched rather than gaining an empty
240    /// `{}` that a plugin would have to special-case.
241    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
257/// Add the host context to one plugin tool call's arguments.
258///
259/// Injected here — inside the adapter, on a copy — rather than in the agent loop
260/// or in `AgentTool::prepare_arguments`, so the field reaches the plugin and
261/// nothing else: not the model, not `ToolExecutionStart`/`Update` events, not
262/// the session log. It also means a model cannot forge it.
263///
264/// Only object-shaped arguments can carry the key. A plugin whose schema takes
265/// an array or a scalar gets its arguments untouched; `null` (the shape a
266/// no-parameter tool call arrives in) is promoted to an object so such a tool
267/// still receives the context.
268fn 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
286// ---------------------------------------------------------------------------
287// PluginToolAdapter — AgentTool impl driving the 4-fn handle
288// ---------------------------------------------------------------------------
289
290/// An [`AgentTool`] backed by a plugin's 4-function handle. One adapter is built
291/// per registered tool (`schema` copied from the registration) and inserted into
292/// the session's tool set in B2.
293///
294/// Holds a clone of the session's [`PluginKeepalive`] so the cdylib that owns
295/// `handle`'s fn pointers stays mapped for as long as the adapter (and thus any
296/// in-flight `execute`) may call them. Without this the `Library` could drop
297/// (unload the cdylib) while a fn pointer is still callable → UAF. The keepalive
298/// is `Arc`-shared with every other adapter built from the same session, so the
299/// last drop — which can only happen once the harness's tool vec drops — unloads.
300pub struct PluginToolAdapter {
301    schema: Tool,
302    label: String,
303    handle: PluginToolHandle,
304    /// Injected into every call's arguments under
305    /// [`rpi_plugin_sdk::HOST_CONTEXT_KEY`]. `Default` (nothing known) until the
306    /// CLI attaches the session's context, so an adapter built without one — as
307    /// the tests and the SDK smoke test do — behaves exactly as before.
308    context: ToolCallContext,
309    // Drop order: `keepalive` is declared AFTER `handle` so the cdylib unloads
310    // only after the fn-pointer bundle is itself dropped — though since both are
311    // fine to drop in any order (fn pointers are Copy, the real call sites are
312    // all inside `execute` which holds `&self`, so the adapter is never dropped
313    // mid-call), this is belt-and-suspenders.
314    #[allow(dead_code)]
315    keepalive: Arc<PluginKeepalive>,
316}
317
318impl PluginToolAdapter {
319    /// Build an adapter from the registered schema + handle + the session's
320    /// keepalive. `label` defaults to the tool name. The keepalive clone keeps
321    /// the owning cdylib mapped for the adapter's lifetime.
322    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    /// Attach the session's context, so each call carries `cwd` + `sessionId`.
334    /// Builder-style so the three-argument [`PluginToolAdapter::new`] keeps
335    /// working for callers that have no session (tests, the SDK smoke test).
336    pub fn with_context(mut self, context: ToolCallContext) -> Self {
337        self.context = context;
338        self
339    }
340}
341
342/// The partial-callback trampoline passed to `poll`. `user_data` is a
343/// `*const mpsc::UnboundedSender<AgentToolResult>` valid for the drive (the
344/// blocking driver owns the sender in its closure env). The plugin invokes this
345/// synchronously inside `poll()`; it parses the partial, frees the plugin-
346/// produced `StbString` via the host's `free_string`, and pushes the result
347/// through the mpsc. Wrapped in `catch_unwind` so a poisoned sender / panic
348/// cannot unwind across FFI (abort-on-unwind).
349extern "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            // Still must free the partial (plugin produced it; host owns
353            // cleanup when no handler runs — here "no handler" means no sender).
354            host_free_string(partial);
355            return;
356        }
357        // SAFETY: the blocking driver guarantees `user_data` is a live
358        // `&mpsc::UnboundedSender<AgentToolResult>` for the duration of poll().
359        let sender = unsafe { &*(user_data as *const mpsc::UnboundedSender<AgentToolResult>) };
360        let result = stb_to_result(&partial);
361        // The partial StbString was plugin-produced inside poll(); the host is
362        // the receiver and owns the free (plugin allocated with the global
363        // allocator, which host_free_string reclaims — documented v1 contract).
364        host_free_string(partial);
365        let _ = sender.send(result);
366    }));
367    if outcome.is_err() {
368        // A panic inside the *host-side* closure (e.g. a malformed partial that
369        // made `stb_to_result` panic) was caught above. Do NOT abort the
370        // process: log it and move on. The partial StbString may leak on this
371        // rare path, which is preferable to taking the host down.
372        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        // Plugin tools default to Parallel (the AgentTool default). A plugin
388        // could declare Sequential via a future schema field; v1 keeps Parallel.
389        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        // 1. Acquire the ambient runtime (the adapter only runs inside the agent
400        //    loop's runtime). Do NOT own a runtime.
401        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        // 2. Bridges: unbounded mpsc for partials, oneshot for terminal.
409        let (partial_tx, mut partial_rx) = mpsc::unbounded_channel::<AgentToolResult>();
410        let (done_tx, done_rx) = oneshot::channel::<Result<AgentToolResult, AgentError>>();
411
412        // The cancel flag the blocking driver observes. Set by `signal.cancelled()`
413        // (and by the drop guard, were one needed). SeqCst for cross-thread
414        // visibility with the blocking driver.
415        let cancel_flag = Arc::new(AtomicBool::new(false));
416
417        // 3. Prepare plugin execute() inputs. `params` is an owning JSON string
418        //    the host produced → the plugin frees it via the host's free_string.
419        //    `tool_call_id` is borrowed for the call. The host context is merged
420        //    into the arguments here — after the model's arguments were parsed,
421        //    before the plugin sees them, and never into the events or the log.
422        let params = inject_tool_context(params, &self.context);
423        let params_json = serde_json::to_string(&params).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        // 4. spawn_blocking driver. It runs to completion regardless of
437        //    outer-future drop, so we NEVER drop it; on cancel we set the flag
438        //    and keep awaiting done_rx. The sender is captured (Send); the raw
439        //    pointer to it is computed INSIDE the closure (not moved across
440        //    threads — `*mut c_void` is not `Send`).
441        let cancel_flag_drive = Arc::clone(&cancel_flag);
442        let sender_for_cb = partial_tx.clone();
443        runtime.spawn_blocking(move || {
444            // Drive: execute → poll loop → destroy exactly once. Every plugin
445            // call is extern "C"; wrap in catch_unwind so a plugin panic cannot
446            // unwind across FFI (abort-on-unwind).
447            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                        // null handle — allocation failure / refused.
455                        let _ = done_tx.send(Err(AgentError::Tool(format!(
456                            "plugin execute returned null handle for '{schema_name}'"
457                        ))));
458                        return;
459                    }
460                    Err(_) => {
461                        // Reached only when the panic unwound into the host
462                        // (plugin `extern "C"` panics abort at the plugin frame
463                        // before we get here). Report a tool error instead of
464                        // aborting the process.
465                        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            // The sender pointer, valid for the drive (sender_for_cb lives in
475            // this closure env). Computed here so no raw pointer crosses threads.
476            let sender_ptr = &sender_for_cb as *const _ as *mut c_void;
477
478            // poll loop
479            let terminal: Result<AgentToolResult, AgentError> = loop {
480                if cancel_flag_drive.load(Ordering::SeqCst) {
481                    // Observe cancel: tell the plugin, then break with an abort.
482                    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                        // SAFETY: tag == Pending.
499                        let progress = unsafe { step_result.pending_payload().progress };
500                        if !progress.is_empty() {
501                            // The plugin may also have pushed via the partial cb;
502                            // both paths land in partial_rx. Forward this one too.
503                            let pr = stb_to_result(&progress);
504                            // progress was plugin-produced inside poll → free via
505                            // the plugin's free_string.
506                            (plugin_free)(progress);
507                            let _ = partial_tx.send(pr);
508                        }
509                        continue;
510                    }
511                    StepResultTag::Done => {
512                        // SAFETY: tag == Done.
513                        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                        // SAFETY: tag == Err.
520                        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            // destroy exactly once — idempotent, called by the driver only.
529            let _ = catch_unwind(AssertUnwindSafe(|| (destroy_fn)(step_handle)));
530            let _ = done_tx.send(terminal);
531        });
532
533        // 5. Async side: poll the terminal oneshot and the cancel signal in a
534        //    loop. On cancel: set the AtomicBool (driver observes it) and KEEP
535        //    polling the same oneshot (never drop the driver — spawn_blocking
536        //    runs to completion; dropping is a thread leak). Pinning both
537        //    futures lets us re-poll `done_rx` after `cancelled` fired without
538        //    moving it (a plain `select!` would consume it on the first fire).
539        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                    // Set the flag once; the driver observes it and breaks. Keep
556                    // looping so we still poll done_rx to completion.
557                    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                    // Yield so we don't busy-spin against the driver.
562                    tokio::task::yield_now().await;
563                }
564            }
565        };
566
567        // Final drain of any partials that arrived between the select! branch and here.
568        while let Ok(p) = partial_rx.try_recv() {
569            on_update(p);
570        }
571
572        result
573    }
574}
575
576// ---------------------------------------------------------------------------
577// Tests — the ABI bridge against an in-process stub plugin
578// ---------------------------------------------------------------------------
579
580#[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    // These three tests share process-global stub counters (the stub fns are
589    // `extern "C"` and cannot capture per-test state). Parallel execution would
590    // have one test's `reset_counters` wipe another's in-flight increments. Hold
591    // this lock for the ENTIRE test body — the spawn_blocking driver finishes
592    // (destroy fires) before the awaited `execute` returns, so releasing the
593    // guard after the await means no driver outlives its test's reset window.
594    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    /// The raw arguments string the most recent `stub_execute` was handed. The
600    /// stub used to ignore its `params` entirely, which made it impossible to
601    /// assert that the host context actually crosses the FFI boundary — the one
602    /// thing about the injection that unit-testing the merge function cannot
603    /// prove.
604    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        // No hang + destroy exactly once (cancel observed via the AtomicBool).
829        assert_eq!(
830            DESTROY_COUNT.load(Ordering::SeqCst),
831            1,
832            "destroy once on cancel"
833        );
834    }
835
836    // ---- host context injection ----
837
838    #[test]
839    fn injected_context_carries_cwd_and_session_only_when_known() {
840        // Nothing known yet → no key at all, rather than an empty object a
841        // plugin would have to special-case.
842        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        // A no-argument call arrives as `null`; it should still receive the
866        // context instead of being left unusable as an object.
867        let merged = inject_tool_context(Value::Null, &context);
868        assert_eq!(merged["__rpi"]["cwd"], "/proj");
869
870        // Array/scalar parameters cannot carry a key — pass through untouched.
871        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}