Skip to main content

aacode_rs/
ffi.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! C ABI for embedding aacode-rs into mobile hosts (Android via `jni_glue.c`,
5//! iOS via the Swift bridge) and other hosts.
6//!
7//! Contract (handle-based, userdata-carrying, single event stream):
8//!   * `aacode_task_start(task_json, cb, userdata)` — non-blocking. Returns an
9//!     opaque handle. Each JSONL event line is delivered via `cb(line, userdata)`
10//!     from the tokio worker threads. Early failures (bad JSON, missing task,
11//!     session already running, init/cd failure) emit an `error` event and a
12//!     handle whose `wait` immediately returns the error.
13//!   * `aacode_task_wait(handle)` — blocks until the task finishes; returns a
14//!     JSON result string (caller frees with `aacode_free_string`).
15//!   * `aacode_task_cancel(handle)` — non-blocking; the running task observes
16//!     it asynchronously.
17//!   * `aacode_task_free(handle)` — free a finished handle.
18//!   * `aacode_validate_api_key(config_json)` — returns `{"valid":bool,...}`.
19//!   * `aacode_list_sessions(project_path)` / `aacode_get_session_messages(...)`.
20//!   * `aacode_free_string(ptr)` — free any returned string.
21//!
22//! The terminal event is an enriched `done` (`{"type":"done","session_id":...,
23//! "status":...,"iterations":...,"final_text":...}`); `wait` returns the same
24//! outcome for hosts that prefer a return value. Events are authoritative.
25
26// C-ABI surface: every extern fn validates pointers (null-checked, then
27// CStr::from_ptr) per the documented host contract; `unsafe fn` would not
28// change the C caller's obligations.
29#![allow(clippy::not_unsafe_ptr_arg_deref)]
30
31use crate::config::AgentConfig;
32use crate::runtime::{AgentRuntime, TOKIO_RT};
33use crate::session::{valid_session_id, SessionManager, SessionMessage, SCHEMA_VERSION};
34use crate::stream::CallbackSink;
35use serde_json::json;
36use std::ffi::{c_char, c_void, CStr, CString};
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::mpsc;
39use std::sync::{Arc, Mutex, OnceLock};
40
41/// The C stream callback type: receives one NUL-terminated UTF-8 line plus the
42/// opaque `userdata` context supplied at `aacode_task_start`. The userdata lets
43/// each host bind per-task state without thread-local or global hacks.
44pub type StreamCallback = extern "C" fn(*const c_char, *mut c_void);
45
46/// Opaque task handle returned by `aacode_task_start`.
47pub struct AacodeTask {
48    cancel: Arc<AtomicBool>,
49    result_rx: mpsc::Receiver<String>,
50    /// Sender for a terminal result when the task is aborted (see cancel/free).
51    tx: mpsc::Sender<String>,
52    /// The spawned task, so `cancel`/`free` can abort it.
53    join: Mutex<Option<tokio::task::JoinHandle<()>>>,
54    /// Session key to release **synchronously** on cancel/free. The task future
55    /// (which owns the `SessionGuard`) is only dropped at its next await point
56    /// after `abort()`, so without this a new message on the same session races
57    /// the drop and is rejected with "already has a task running".
58    session_key: Option<String>,
59    /// fastshell cancel flag: set on cancel to abort a running command, which
60    /// would otherwise keep holding the fastshell lock until it finishes (making
61    /// the next command report "could not acquire lock after Ns (busy)").
62    fs_cancel: Option<Arc<AtomicBool>>,
63}
64
65impl AacodeTask {
66    fn new(
67        cancel: Arc<AtomicBool>,
68        result_rx: mpsc::Receiver<String>,
69        tx: mpsc::Sender<String>,
70        join: Option<tokio::task::JoinHandle<()>>,
71        session_key: Option<String>,
72        fs_cancel: Option<Arc<AtomicBool>>,
73    ) -> Self {
74        AacodeTask {
75            cancel,
76            result_rx,
77            tx,
78            join: Mutex::new(join),
79            session_key,
80            fs_cancel,
81        }
82    }
83}
84
85/// Wrapper making the raw `userdata` pointer safe to move into the tokio task
86/// (the host guarantees the pointed-to context outlives the task).
87struct SendUserData(*mut c_void);
88// SAFETY: the host contract requires `userdata` to remain valid until the task
89// completes and `aacode_task_free` is called. It is only passed back verbatim
90// to the host callback.
91unsafe impl Send for SendUserData {}
92unsafe impl Sync for SendUserData {}
93
94impl SendUserData {
95    /// Raw pointer access via a method so closures capture the whole wrapper
96    /// (and its `Send`/`Sync` impls) rather than the bare `*mut c_void` field.
97    #[inline]
98    fn ptr(&self) -> *mut c_void {
99        self.0
100    }
101}
102
103/// In-flight session ids (project_path + session_id). Two concurrent tasks on
104/// the SAME session would corrupt the session file (last-writer-wins), so the
105/// second submission is rejected up front.
106static RUNNING_SESSIONS: OnceLock<Mutex<std::collections::HashMap<String, Arc<()>>>> =
107    OnceLock::new();
108
109fn running_sessions() -> &'static Mutex<std::collections::HashMap<String, Arc<()>>> {
110    RUNNING_SESSIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
111}
112
113/// Release a session synchronously. Called on cancel/free so the next message on
114/// the same session can start immediately, instead of racing the async task drop
115/// (which only happens at the cancelled future's next await point).
116fn release_session(key: &str) {
117    running_sessions()
118        .lock()
119        .unwrap_or_else(|e| e.into_inner())
120        .remove(key);
121}
122
123/// RAII guard marking a session as busy. `try_acquire` returns None when the
124/// session already has an in-flight task. Each acquisition gets a unique token
125/// so a cancelled task's late `Drop` never removes a *new* task's guard.
126struct SessionGuard {
127    key: String,
128    token: Arc<()>,
129}
130
131impl SessionGuard {
132    fn try_acquire(key: String) -> Option<Self> {
133        let mut map = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
134        if map.contains_key(&key) {
135            return None;
136        }
137        let token = Arc::new(());
138        map.insert(key.clone(), token.clone());
139        Some(SessionGuard { key, token })
140    }
141}
142
143impl Drop for SessionGuard {
144    fn drop(&mut self) {
145        let mut map = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
146        if let Some(cur) = map.get(&self.key) {
147            if Arc::ptr_eq(cur, &self.token) {
148                map.remove(&self.key);
149            }
150        }
151    }
152}
153
154/// Convert a C string pointer to a Rust &str safely.
155unsafe fn cstr<'a>(ptr: *const c_char) -> Option<&'a str> {
156    if ptr.is_null() {
157        return None;
158    }
159    CStr::from_ptr(ptr).to_str().ok()
160}
161
162/// Allocate a C string from a Rust String (caller frees via aacode_free_string).
163fn to_c_string(s: String) -> *mut c_char {
164    CString::new(s)
165        .unwrap_or_else(|_| CString::new("").unwrap())
166        .into_raw()
167}
168
169/// Run an FFI body, converting a panic into an error JSON instead of letting it
170/// unwind across the `extern "C"` boundary.
171///
172/// On Rust 1.81+ a panic escaping an `extern "C"` function aborts the whole
173/// process — so an `unwrap` on corrupt on-disk session data (or any other
174/// panic) would be a silent crash with no business-level stack. Every
175/// non-task FFI entry point funnels through here so a panic degrades to a
176/// normal error result instead.
177fn guard_cchar(f: impl FnOnce() -> *mut c_char) -> *mut c_char {
178    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
179        Ok(ptr) => ptr,
180        Err(_) => to_c_string(
181            serde_json::json!({"success": false, "error": "internal panic"}).to_string(),
182        ),
183    }
184}
185
186/// Decide the shell sandbox root for the SDK (mobile/embedded host) path.
187/// Returns `(shell_root, cd_rel)`: the VFS root to init fastshell with, and
188/// an optional sandbox-relative path to `cd` into right after init.
189///
190/// Default: the shell is confined to the project. When the host configured a
191/// user skills directory (`config.skills.user_dir`) the agent must be able to
192/// manage skills via run_shell — those may live OUTSIDE the project — so the
193/// shell root is widened to the minimal common ancestor of project and
194/// user_dir. Hosts that don't pass skills.user_dir keep the old
195/// strictly-project-confined behavior.
196///
197/// Widening uses a three-step cascade (first match wins):
198/// 1. Project is a proper subdirectory of sandbox → widen to sandbox.
199/// 2. User dir is already inside project → no widening needed.
200/// 3. Walk project ancestors to find the closest one containing user dir.
201///
202/// Paths are canonicalized and compared **component-wise** (`Path::strip_prefix`),
203/// not by string prefix:
204///  * Android hands out `/data/user/0/<pkg>/…` while the canonical form is
205///    `/data/data/<pkg>/…` (symlink); iOS/macOS have `/var` → `/private/var`.
206///  * A sibling directory sharing a name prefix (`/sb` vs `/sb2/proj`) must
207///    NOT be treated as inside the sandbox — a string check would wrongly
208///    widen the root and then fail the `cd`.
209fn resolve_shell_root(
210    sandbox: &str,
211    project: &str,
212    user_dir: Option<&str>,
213) -> (String, Option<String>) {
214    let Some(user_dir) = user_dir else {
215        return (project.to_string(), None);
216    };
217    let canon = |s: &str| std::fs::canonicalize(s).unwrap_or_else(|_| std::path::PathBuf::from(s));
218    let sandbox_c = canon(sandbox);
219    let project_c = canon(project);
220    let user_dir_c = canon(user_dir);
221
222    // Step 1: project is a proper subdirectory of sandbox → widen to sandbox.
223    match project_c.strip_prefix(&sandbox_c) {
224        Ok(rel) if !rel.as_os_str().is_empty() => {
225            return (sandbox.to_string(), Some(rel.to_string_lossy().to_string()));
226        }
227        _ => {}
228    }
229
230    // Step 2: user_dir is already inside project → no widening needed.
231    if user_dir_c.strip_prefix(&project_c).is_ok() {
232        return (project.to_string(), None);
233    }
234
235    // Step 3: walk project ancestors to find the closest common ancestor
236    //         that also contains user_dir. Stop before the filesystem root.
237    //
238    // Example (iOS): project = /a/b/projects/Test3, user_dir = /a/b/skills
239    //   ancestors of project:  /a/b/projects  → user_dir not inside ✗
240    //                          /a/b            → user_dir inside      ✓  ← widen here
241    //   Result: shell_root = /a/b, cd_rel = projects/Test3
242    for ancestor in project_c.ancestors().skip(1) {
243        if ancestor.parent().is_none() {
244            break;
245        }
246        if user_dir_c.strip_prefix(ancestor).is_ok() {
247            if let Ok(rel) = project_c.strip_prefix(ancestor) {
248                if !rel.as_os_str().is_empty() {
249                    return (
250                        ancestor.to_string_lossy().to_string(),
251                        Some(rel.to_string_lossy().to_string()),
252                    );
253                }
254            }
255        }
256    }
257
258    (project.to_string(), None)
259}
260
261/// Build the agent runtime for the parsed config + project path. Returns an
262/// error string suitable for an `error` event / terminal JSON.
263fn build_runtime(
264    config: &AgentConfig,
265    project_path: &str,
266) -> Result<(AgentRuntime, Option<Arc<AtomicBool>>), String> {
267    if let Some(sdk) = fastshell::sdk::try_get_sdk_instance() {
268        let sandbox = {
269            let guard = sdk.lock().unwrap_or_else(|e| e.into_inner());
270            guard.vfs_root()
271        };
272        let pp = if project_path == "." {
273            sandbox.clone()
274        } else {
275            project_path.to_string()
276        };
277        // Shell sandbox root + optional cwd reposition (see resolve_shell_root).
278        let (shell_root, cd_rel) =
279            resolve_shell_root(&sandbox, &pp, config.skills.user_dir.as_deref());
280        // Compute a VFS-internal skills path (relative to shell root) so
281        // skill prompts can use short, platform-independent paths like
282        // `/skills` instead of long absolute physical paths.
283        let mut cfg = config.clone();
284        if let Some(ref user_dir) = cfg.skills.user_dir {
285            if let Ok(rel) = std::path::Path::new(user_dir).strip_prefix(&shell_root) {
286                if !rel.as_os_str().is_empty() {
287                    cfg.skills.vfs_skills_dir = Some(format!("/{}", rel.to_string_lossy()));
288                }
289            }
290        }
291        let fs_arc: Arc<Mutex<fastshell::Fastshell>> =
292            Arc::new(Mutex::new(fastshell::Fastshell::new()));
293        {
294            let mut ours = fs_arc.lock().unwrap();
295            let mut fscfg = fastshell::Config::default();
296            fscfg.sandbox_path = shell_root.clone();
297            fscfg.python_enabled = true;
298            fscfg.allow_subprocess = false; // mobile sandbox: no subprocess spawn
299            fscfg.network_ask_permission = false;
300            if let Err(e) = ours.init(fscfg) {
301                return Err(e.to_string());
302            }
303            if let Some(rel) = cd_rel {
304                // Position the persistent shell session inside the project so
305                // relative paths behave exactly as before the root widening.
306                let r = ours.execute(&format!("cd '{}'", rel.replace('\'', "'\\''")));
307                if r.exit_code != 0 {
308                    return Err(format!("cd to project failed: {}", r.stderr));
309                }
310            }
311        }
312        let fs_cancel = fs_arc
313            .lock()
314            .unwrap_or_else(|e| e.into_inner())
315            .cancel_handle();
316        Ok((
317            AgentRuntime::with_fastshell(cfg, std::path::PathBuf::from(pp), fs_arc),
318            Some(fs_cancel),
319        ))
320    } else {
321        AgentRuntime::init(config.clone(), std::path::PathBuf::from(project_path))
322            .map(|rt| (rt, None))
323            .map_err(|e| e.to_string())
324    }
325}
326
327/// Start a task described by `task_json`. Non-blocking; events stream through
328/// `cb(line, userdata)`. Returns an opaque handle (NULL on catastrophic OOM).
329#[no_mangle]
330pub extern "C" fn aacode_task_start(
331    task_json: *const c_char,
332    cb: Option<StreamCallback>,
333    userdata: *mut c_void,
334) -> *mut AacodeTask {
335    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
336    let (tx, rx) = mpsc::channel::<String>();
337    let cancel = Arc::new(AtomicBool::new(false));
338
339    let ud = SendUserData(userdata);
340    let emit = move |line: &str| {
341        if let Some(cb) = cb {
342            if let Ok(cs) = CString::new(line) {
343                cb(cs.as_ptr(), ud.ptr());
344            }
345        }
346    };
347
348    let input = match unsafe { cstr(task_json) } {
349        Some(s) => s.to_string(),
350        None => {
351            let msg = "null task_json";
352            emit(&json!({"type": "error", "message": msg}).to_string());
353            let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
354            return Box::into_raw(Box::new(AacodeTask::new(
355                cancel,
356                rx,
357                tx.clone(),
358                None,
359                None,
360                None,
361            )));
362        }
363    };
364
365    let v: serde_json::Value = match serde_json::from_str(&input) {
366        Ok(v) => v,
367        Err(e) => {
368            let msg = format!("bad task_json: {e}");
369            emit(&json!({"type": "error", "message": msg}).to_string());
370            let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
371            return Box::into_raw(Box::new(AacodeTask::new(
372                cancel,
373                rx,
374                tx.clone(),
375                None,
376                None,
377                None,
378            )));
379        }
380    };
381    let task = v
382        .get("task")
383        .and_then(|x| x.as_str())
384        .unwrap_or("")
385        .to_string();
386    if task.is_empty() {
387        let msg = "missing task";
388        emit(&json!({"type": "error", "message": msg}).to_string());
389        let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
390        return Box::into_raw(Box::new(AacodeTask::new(
391            cancel,
392            rx,
393            tx.clone(),
394            None,
395            None,
396            None,
397        )));
398    }
399    let project_path = v
400        .get("project_path")
401        .and_then(|x| x.as_str())
402        .unwrap_or(".")
403        .to_string();
404    let session_id = v
405        .get("session_id")
406        .and_then(|x| x.as_str())
407        .map(|s| s.to_string());
408
409    // Reject a second concurrent task on the same session (would corrupt the
410    // session file). New sessions (no session_id) are always allowed.
411    let session_key: Option<String> = session_id
412        .as_ref()
413        .map(|sid| format!("{project_path}::{sid}"));
414    let session_guard = match &session_key {
415        Some(key) => match SessionGuard::try_acquire(key.clone()) {
416            Some(g) => Some(g),
417            None => {
418                let sid = session_id.as_deref().unwrap_or("");
419                let msg = format!("session '{sid}' already has a task running");
420                emit(&json!({"type": "error", "message": msg}).to_string());
421                let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
422                return Box::into_raw(Box::new(AacodeTask::new(
423                    cancel,
424                    rx,
425                    tx.clone(),
426                    None,
427                    None,
428                    None,
429                )));
430            }
431        },
432        None => None,
433    };
434
435    let mut config: AgentConfig = serde_json::from_value(v).unwrap_or_default();
436    config.apply_env();
437
438    let (rt, fs_cancel) = match build_runtime(&config, &project_path) {
439        Ok(x) => x,
440        Err(e) => {
441            emit(&json!({"type": "error", "message": e}).to_string());
442            let _ = tx.send(json!({"status": "error", "error": e}).to_string());
443            return Box::into_raw(Box::new(AacodeTask::new(
444                cancel,
445                rx,
446                tx.clone(),
447                None,
448                None,
449                None,
450            )));
451        }
452    };
453
454    let cancel2 = cancel.clone();
455    let tx_for_handle = tx.clone();
456    let join = TOKIO_RT.spawn(async move {
457        // Hold the session guard for the duration of the task.
458        let _guard = session_guard;
459        // Guard against a panic in the agent (e.g. an `unwrap` on bad data):
460        // without this, a panic leaves `result_rx` open forever and
461        // `aacode_task_wait` blocks until the host's 180-minute timeout — a
462        // silent "task interrupted". Turn a panic into a normal error result.
463        use futures::FutureExt;
464        let result = std::panic::AssertUnwindSafe(rt.run_task(
465            &task,
466            session_id.as_deref(),
467            &CallbackSink::new(Box::new(emit)),
468            &cancel2,
469        ))
470        .catch_unwind()
471        .await;
472        let json = match result {
473            Ok(Ok(res)) => res.to_result_json().to_string(),
474            Ok(Err(e)) => json!({"status": "error", "error": e.to_string()}).to_string(),
475            Err(_) => {
476                json!({"status": "error", "error": "agent panicked (internal error)"}).to_string()
477            }
478        };
479        let _ = tx.send(json);
480    });
481
482    Box::into_raw(Box::new(AacodeTask::new(
483        cancel,
484        rx,
485        tx_for_handle,
486        Some(join),
487        session_key,
488        fs_cancel,
489    )))
490}
491
492/// Block until the task finishes; return the terminal JSON result string
493/// (caller frees with `aacode_free_string`).
494#[no_mangle]
495pub extern "C" fn aacode_task_wait(handle: *mut AacodeTask) -> *mut c_char {
496    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
497    if handle.is_null() {
498        return to_c_string(json!({"status": "error", "error": "null handle"}).to_string());
499    }
500    let h = unsafe { &*handle };
501    let result = h.result_rx.recv().unwrap_or_else(|_| {
502        json!({"status": "error", "error": "task was dropped before completion"}).to_string()
503    });
504    to_c_string(result)
505}
506
507/// Signal cancellation. Non-blocking; the running task observes it async.
508#[no_mangle]
509pub extern "C" fn aacode_task_cancel(handle: *mut AacodeTask) {
510    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
511    if handle.is_null() {
512        return;
513    }
514    let h = unsafe { &*handle };
515    h.cancel.store(true, Ordering::SeqCst);
516    // Abort a running command so it stops holding the fastshell lock (tokio
517    // cannot cancel a running `spawn_blocking` task, so the flag is the only
518    // way to release the lock promptly).
519    if let Some(c) = &h.fs_cancel {
520        c.store(true, Ordering::SeqCst);
521    }
522    // Abort the task future (so it is dropped at its next await point).
523    if let Some(join) = h.join.lock().unwrap_or_else(|e| e.into_inner()).take() {
524        join.abort();
525    }
526    // Release the session **synchronously** so the next message on the same
527    // session can start immediately (the aborted future may not have been
528    // dropped yet).
529    if let Some(k) = &h.session_key {
530        release_session(k);
531    }
532    // Best-effort terminal result for hosts that call `wait` after cancelling.
533    let _ = h.tx.send(json!({"status": "cancelled"}).to_string());
534}
535
536/// Free a finished handle.
537#[no_mangle]
538pub extern "C" fn aacode_task_free(handle: *mut AacodeTask) {
539    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
540    if handle.is_null() {
541        return;
542    }
543    // Safety net: if the host frees a still-running task without cancelling it,
544    // abort so it does not keep holding the session guard.
545    {
546        let h = unsafe { &*handle };
547        if let Some(join) = h.join.lock().unwrap_or_else(|e| e.into_inner()).take() {
548            join.abort();
549            if let Some(c) = &h.fs_cancel {
550                c.store(true, Ordering::SeqCst);
551            }
552        }
553        if let Some(k) = &h.session_key {
554            release_session(k);
555        }
556    }
557    unsafe {
558        drop(Box::from_raw(handle));
559    }
560}
561
562/// Validate an API key. `config_json` should contain a `model` object (or the
563/// flat model fields). Returns `{"valid":bool,"error":...}`.
564#[no_mangle]
565pub extern "C" fn aacode_validate_api_key(config_json: *const c_char) -> *mut c_char {
566    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
567    guard_cchar(|| {
568        let input = match unsafe { cstr(config_json) } {
569            Some(s) => s.to_string(),
570            None => {
571                return to_c_string(json!({"valid": false, "error": "null config"}).to_string())
572            }
573        };
574        let mut config: AgentConfig = serde_json::from_str(&input).unwrap_or_default();
575        config.apply_env();
576        let client = crate::llm::build_client(&config.model);
577        let handle = TOKIO_RT.handle().clone();
578        let out = std::thread::scope(|s| {
579            s.spawn(
580                move || match handle.block_on(async { client.validate().await }) {
581                    Ok(()) => json!({"valid": true}),
582                    Err(e) => json!({"valid": false, "error": e.to_string()}),
583                },
584            )
585            // A panic in the validation thread must not abort the process.
586            .join()
587            .unwrap_or_else(|_| json!({"valid": false, "error": "validation thread panicked"}))
588        });
589        to_c_string(out.to_string())
590    })
591}
592
593/// List sessions for a project path. Returns a JSON array.
594#[no_mangle]
595pub extern "C" fn aacode_list_sessions(project_path: *const c_char) -> *mut c_char {
596    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
597    guard_cchar(|| {
598        let pp = match unsafe { cstr(project_path) } {
599            Some(s) => s.to_string(),
600            None => {
601                return to_c_string(json!({"success": false, "error": "null path"}).to_string())
602            }
603        };
604        let sm = SessionManager::new(std::path::Path::new(&pp));
605        let sessions: Vec<serde_json::Value> = sm
606            .list_sessions()
607            .into_iter()
608            .map(|s| {
609                json!({
610                    "session_id": s.session_id,
611                    "title": s.title,
612                    "created_at": s.created_at,
613                    "last_activity": s.last_activity,
614                    "total_messages": s.total_messages,
615                    "status": s.status,
616                })
617            })
618            .collect();
619        to_c_string(json!({"success": true, "sessions": sessions}).to_string())
620    })
621}
622
623/// Get the messages of a session. Returns a JSON array of {role, content, ...}.
624#[no_mangle]
625pub extern "C" fn aacode_get_session_messages(
626    project_path: *const c_char,
627    session_id: *const c_char,
628) -> *mut c_char {
629    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
630    guard_cchar(|| {
631        let pp = unsafe { cstr(project_path) }.unwrap_or("").to_string();
632        let sid = unsafe { cstr(session_id) }.unwrap_or("").to_string();
633        let sm = SessionManager::new(std::path::Path::new(&pp));
634        let msgs = sm.read_session_messages(&sid);
635        let arr: Vec<serde_json::Value> = msgs
636            .iter()
637            .map(|m| {
638                json!({
639                    "role": m.role,
640                    "content": m.content,
641                    "timestamp": m.timestamp,
642                    "tool_calls": m.tool_calls,
643                    "tool_call_id": m.tool_call_id,
644                    "reasoning_content": m.reasoning_content,
645                })
646            })
647            .collect();
648        to_c_string(json!({"success": true, "messages": arr}).to_string())
649    })
650}
651
652// ── Session store FFI (see SESSION_FFI.md) ─────────────────────────
653
654/// Serializes all session-store write FFI calls so concurrent callers can't
655/// interleave read-modify-write on the shared index/session files (in-process
656/// serialization; the agent's own writes are not under this lock).
657static SESSION_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
658
659/// Serialize one persisted message into the SESSION_FFI wire shape.
660fn message_to_json(m: &SessionMessage) -> serde_json::Value {
661    json!({
662        "role": m.role,
663        "content": m.content,
664        "timestamp": m.timestamp,
665        "tokens": m.tokens,
666        "tool_calls": m.tool_calls,
667        "tool_call_id": m.tool_call_id,
668        "reasoning_content": m.reasoning_content,
669    })
670}
671
672/// Build the `{"success": false, "error": ...}` failure envelope.
673fn session_err(msg: &str) -> *mut c_char {
674    to_c_string(json!({"success": false, "error": msg}).to_string())
675}
676
677/// Parse `project` + `session_id` C strings into owned Strings, validating the
678/// session id so it cannot escape the sessions directory.
679fn session_id_args(
680    project_path: *const c_char,
681    session_id: *const c_char,
682) -> Result<(String, String), String> {
683    let pp = unsafe { cstr(project_path) }
684        .ok_or_else(|| "null project_path".to_string())?
685        .to_string();
686    let sid = unsafe { cstr(session_id) }
687        .ok_or_else(|| "null session_id".to_string())?
688        .to_string();
689    if !valid_session_id(&sid) {
690        return Err("invalid session_id".to_string());
691    }
692    Ok((pp, sid))
693}
694
695/// Current on-disk session schema version (see SESSION_FFI.md §6).
696#[no_mangle]
697pub extern "C" fn aacode_session_version() -> u32 {
698    SCHEMA_VERSION
699}
700
701/// List sessions for a project (SESSION_FFI contract). Returns a JSON array.
702#[no_mangle]
703pub extern "C" fn aacode_session_list(project_path: *const c_char) -> *mut c_char {
704    guard_cchar(|| {
705        let pp = match unsafe { cstr(project_path) } {
706            Some(s) => s.to_string(),
707            None => return session_err("null project_path"),
708        };
709        let sm = SessionManager::new(std::path::Path::new(&pp));
710        let sessions: Vec<serde_json::Value> = sm
711            .list_sessions()
712            .into_iter()
713            .map(|s| {
714                json!({
715                    "session_id": s.session_id,
716                    "title": s.title,
717                    "created_at": s.created_at,
718                    "last_activity": s.last_activity,
719                    "total_messages": s.total_messages,
720                    "total_tokens": s.total_tokens,
721                    "status": s.status,
722                })
723            })
724            .collect();
725        to_c_string(json!({"success": true, "sessions": sessions}).to_string())
726    })
727}
728
729/// Paginated session messages (SESSION_FFI contract).
730#[no_mangle]
731pub extern "C" fn aacode_session_messages(
732    project_path: *const c_char,
733    session_id: *const c_char,
734    offset: u32,
735    limit: u32,
736) -> *mut c_char {
737    guard_cchar(|| {
738        let (pp, sid) = match session_id_args(project_path, session_id) {
739            Ok(v) => v,
740            Err(e) => return session_err(&e),
741        };
742        let sm = SessionManager::new(std::path::Path::new(&pp));
743        let all = sm.read_session_messages(&sid);
744        let total = all.len();
745        let offset = offset as usize;
746        let limit = limit as usize;
747        let end = total.saturating_sub(offset);
748        let start = end.saturating_sub(limit);
749        let slice: Vec<serde_json::Value> = if start < end {
750            all[start..end].iter().map(message_to_json).collect()
751        } else {
752            Vec::new()
753        };
754        to_c_string(
755            json!({
756                "success": true,
757                "total": total,
758                "offset": offset,
759                "limit": limit,
760                "messages": slice,
761            })
762            .to_string(),
763        )
764    })
765}
766
767/// Idempotent create-or-touch a session (SESSION_FFI contract).
768#[no_mangle]
769pub extern "C" fn aacode_session_ensure(
770    project_path: *const c_char,
771    session_id: *const c_char,
772    title: *const c_char,
773) -> *mut c_char {
774    guard_cchar(|| {
775        let (pp, sid) = match session_id_args(project_path, session_id) {
776            Ok(v) => v,
777            Err(e) => return session_err(&e),
778        };
779        let title = unsafe { cstr(title) }.unwrap_or("").to_string();
780        let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
781        let mut sm = SessionManager::new(std::path::Path::new(&pp));
782        match sm.ensure_session(&sid, &title) {
783            Ok(()) => to_c_string(json!({"success": true}).to_string()),
784            Err(e) => session_err(&e.to_string()),
785        }
786    })
787}
788
789/// Set an explicit title on a session (SESSION_FFI contract).
790#[no_mangle]
791pub extern "C" fn aacode_session_rename(
792    project_path: *const c_char,
793    session_id: *const c_char,
794    title: *const c_char,
795) -> *mut c_char {
796    guard_cchar(|| {
797        let (pp, sid) = match session_id_args(project_path, session_id) {
798            Ok(v) => v,
799            Err(e) => return session_err(&e),
800        };
801        let title = unsafe { cstr(title) }.unwrap_or("").to_string();
802        let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
803        let mut sm = SessionManager::new(std::path::Path::new(&pp));
804        match sm.rename_session(&sid, &title) {
805            Ok(()) => to_c_string(json!({"success": true}).to_string()),
806            Err(e) => session_err(&e.to_string()),
807        }
808    })
809}
810
811/// Bump `last_activity` of a session (SESSION_FFI contract).
812#[no_mangle]
813pub extern "C" fn aacode_session_touch(
814    project_path: *const c_char,
815    session_id: *const c_char,
816) -> *mut c_char {
817    guard_cchar(|| {
818        let (pp, sid) = match session_id_args(project_path, session_id) {
819            Ok(v) => v,
820            Err(e) => return session_err(&e),
821        };
822        let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
823        let mut sm = SessionManager::new(std::path::Path::new(&pp));
824        match sm.touch_session(&sid) {
825            Ok(()) => to_c_string(json!({"success": true}).to_string()),
826            Err(e) => session_err(&e.to_string()),
827        }
828    })
829}
830
831/// Delete a session (idempotent; SESSION_FFI contract).
832#[no_mangle]
833pub extern "C" fn aacode_session_delete(
834    project_path: *const c_char,
835    session_id: *const c_char,
836) -> *mut c_char {
837    guard_cchar(|| {
838        let (pp, sid) = match session_id_args(project_path, session_id) {
839            Ok(v) => v,
840            Err(e) => return session_err(&e),
841        };
842        let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
843        let mut sm = SessionManager::new(std::path::Path::new(&pp));
844        match sm.delete_session(&sid) {
845            Ok(_) => to_c_string(json!({"success": true}).to_string()),
846            Err(e) => session_err(&e.to_string()),
847        }
848    })
849}
850
851/// Append messages to a session (SESSION_FFI contract).
852#[no_mangle]
853pub extern "C" fn aacode_session_append(
854    project_path: *const c_char,
855    session_id: *const c_char,
856    msgs_json: *const c_char,
857) -> *mut c_char {
858    guard_cchar(|| {
859        let (pp, sid) = match session_id_args(project_path, session_id) {
860            Ok(v) => v,
861            Err(e) => return session_err(&e),
862        };
863        let msgs_str = match unsafe { cstr(msgs_json) } {
864            Some(s) => s,
865            None => return session_err("null msgs_json"),
866        };
867        let msgs: Vec<SessionMessage> = match serde_json::from_str(msgs_str) {
868            Ok(v) => v,
869            Err(e) => return session_err(&format!("bad msgs_json: {e}")),
870        };
871        let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
872        let mut sm = SessionManager::new(std::path::Path::new(&pp));
873        match sm.append_session_messages(&sid, msgs) {
874            Ok(()) => to_c_string(json!({"success": true}).to_string()),
875            Err(e) => session_err(&e.to_string()),
876        }
877    })
878}
879
880/// Register the host WebView backend used by the browser tools (feature
881/// `browser`). Call **once, before starting any task**, and before the browser
882/// is first used. `ops` points at an `FbWebViewOps` table (see fastbrowser's
883/// `fastbrowser.h`); the host must keep it alive for the process lifetime.
884///
885/// Returns 0 on success, non-zero on error. Safe to call when the `browser`
886/// feature is disabled — it simply reports unsupported (-1).
887#[no_mangle]
888pub unsafe extern "C" fn aacode_browser_register_webview_ops(ops: *const c_void) -> i32 {
889    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
890    #[cfg(feature = "browser")]
891    {
892        if ops.is_null() {
893            return -1;
894        }
895        fastbrowser::sdk::ffi::fastbrowser_register_webview_ops(
896            ops as *const fastbrowser::sdk::ffi::FbWebViewOps,
897        )
898    }
899    #[cfg(not(feature = "browser"))]
900    {
901        let _ = ops;
902        -1
903    }
904}
905
906/// Register the host native-surface backend (Android `AccessibilityService`)
907/// used by the desktop/`ax_*` tools. Call **once, before starting any task**.
908/// `ops` points at an `FbSurfaceOps` table (see fastbrowser's `fastbrowser.h`);
909/// the host must keep it alive for the process lifetime.
910///
911/// Returns 0 on success, non-zero on error. Safe to call when the `browser`
912/// feature is disabled (reports -1).
913#[no_mangle]
914pub unsafe extern "C" fn aacode_browser_register_surface_ops(ops: *const c_void) -> i32 {
915    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
916    #[cfg(feature = "browser")]
917    {
918        if ops.is_null() {
919            return -1;
920        }
921        fastbrowser::sdk::ffi::fastbrowser_register_surface_ops(
922            ops as *const fastbrowser::sdk::ffi::FbSurfaceOps,
923        )
924    }
925    #[cfg(not(feature = "browser"))]
926    {
927        let _ = ops;
928        -1
929    }
930}
931
932/// Whether the browser backend is available (feature `browser` compiled **and**
933/// a host WebView registered). Returns 1/0.
934#[no_mangle]
935pub extern "C" fn aacode_browser_available() -> i32 {
936    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
937    #[cfg(feature = "browser")]
938    {
939        if crate::tools::browser::is_available() {
940            1
941        } else {
942            0
943        }
944    }
945    #[cfg(not(feature = "browser"))]
946    {
947        0
948    }
949}
950
951/// Free a string previously returned by this library.
952#[no_mangle]
953pub extern "C" fn aacode_free_string(ptr: *mut c_char) {
954    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
955    if ptr.is_null() {
956        return;
957    }
958    unsafe {
959        let _ = CString::from_raw(ptr);
960    }
961}
962
963// ── Interactive terminal (feature `pty`; Unix only) ─────────────────────────
964//
965// Host/user API — the model gets no terminal tools. Each function returns a
966// JSON string (free with `aacode_free_string`).
967
968#[cfg(feature = "pty")]
969static TERMINALS: OnceLock<Mutex<crate::terminal::TerminalManager>> = OnceLock::new();
970
971#[cfg(feature = "pty")]
972fn terminals() -> &'static Mutex<crate::terminal::TerminalManager> {
973    TERMINALS.get_or_init(|| {
974        Mutex::new(crate::terminal::TerminalManager::new(
975            crate::terminal::MAX_SESSIONS,
976        ))
977    })
978}
979
980/// Open a PTY session. `shell`/`cwd` may be null (defaults: `/bin/sh`, `.`).
981#[cfg(feature = "pty")]
982#[no_mangle]
983pub extern "C" fn aacode_terminal_open(
984    shell: *const c_char,
985    cwd: *const c_char,
986    cols: u16,
987    rows: u16,
988) -> *mut c_char {
989    let shell = unsafe { cstr(shell) }.unwrap_or("/bin/sh");
990    let cwd = unsafe { cstr(cwd) }.unwrap_or(".");
991    let res = terminals()
992        .lock()
993        .unwrap()
994        .start(shell, std::path::Path::new(cwd), &[], cols, rows);
995    match res {
996        Ok(id) => to_c_string(json!({"ok": true, "id": id}).to_string()),
997        Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
998    }
999}
1000
1001/// Write raw bytes to the session.
1002#[cfg(feature = "pty")]
1003#[no_mangle]
1004pub extern "C" fn aacode_terminal_write(
1005    id: *const c_char,
1006    data: *const u8,
1007    len: usize,
1008) -> *mut c_char {
1009    let Some(id) = (unsafe { cstr(id) }) else {
1010        return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1011    };
1012    if data.is_null() {
1013        return to_c_string(json!({"ok": false, "error": "null data"}).to_string());
1014    }
1015    let bytes = unsafe { std::slice::from_raw_parts(data, len) };
1016    match terminals().lock().unwrap().write(id, bytes) {
1017        Ok(()) => to_c_string(json!({"ok": true}).to_string()),
1018        Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1019    }
1020}
1021
1022/// Read output from `cursor`. Returns base64 data + cursor/eof/exit/truncated.
1023#[cfg(feature = "pty")]
1024#[no_mangle]
1025pub extern "C" fn aacode_terminal_read(
1026    id: *const c_char,
1027    cursor: u64,
1028    max_bytes: usize,
1029) -> *mut c_char {
1030    use base64::Engine as _;
1031    let Some(id) = (unsafe { cstr(id) }) else {
1032        return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1033    };
1034    match terminals().lock().unwrap().read(id, cursor, max_bytes) {
1035        Ok(r) => {
1036            let b64 = base64::engine::general_purpose::STANDARD.encode(&r.data);
1037            to_c_string(
1038                json!({
1039                    "ok": true,
1040                    "data_b64": b64,
1041                    "cursor": r.cursor,
1042                    "eof": r.eof,
1043                    "exit_code": r.exit_code,
1044                    "truncated": r.truncated,
1045                })
1046                .to_string(),
1047            )
1048        }
1049        Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1050    }
1051}
1052
1053/// Resize the PTY.
1054#[cfg(feature = "pty")]
1055#[no_mangle]
1056pub extern "C" fn aacode_terminal_resize(id: *const c_char, cols: u16, rows: u16) -> *mut c_char {
1057    let Some(id) = (unsafe { cstr(id) }) else {
1058        return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1059    };
1060    match terminals().lock().unwrap().resize(id, cols, rows) {
1061        Ok(()) => to_c_string(json!({"ok": true}).to_string()),
1062        Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1063    }
1064}
1065
1066/// Close a session (kills the process group).
1067#[cfg(feature = "pty")]
1068#[no_mangle]
1069pub extern "C" fn aacode_terminal_close(id: *const c_char) -> *mut c_char {
1070    let Some(id) = (unsafe { cstr(id) }) else {
1071        return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1072    };
1073    match terminals().lock().unwrap().close(id) {
1074        Ok(()) => to_c_string(json!({"ok": true}).to_string()),
1075        Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1076    }
1077}
1078
1079/// List active sessions.
1080#[cfg(feature = "pty")]
1081#[no_mangle]
1082pub extern "C" fn aacode_terminal_list() -> *mut c_char {
1083    let list = terminals().lock().unwrap().list();
1084    let sessions: Vec<serde_json::Value> = list
1085        .into_iter()
1086        .map(|s| {
1087            json!({
1088                "id": s.id,
1089                "elapsed_ms": s.elapsed_ms,
1090                "eof": s.eof,
1091                "exit_code": s.exit_code,
1092                "buffered": s.buffered,
1093            })
1094        })
1095        .collect();
1096    to_c_string(json!({"ok": true, "sessions": sessions}).to_string())
1097}
1098
1099// Stubs so hosts can always link the symbols; they fail clearly when the
1100// feature is not compiled in.
1101#[cfg(not(feature = "pty"))]
1102#[no_mangle]
1103pub extern "C" fn aacode_terminal_open(
1104    _shell: *const c_char,
1105    _cwd: *const c_char,
1106    _cols: u16,
1107    _rows: u16,
1108) -> *mut c_char {
1109    to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1110}
1111
1112#[cfg(not(feature = "pty"))]
1113#[no_mangle]
1114pub extern "C" fn aacode_terminal_write(
1115    _id: *const c_char,
1116    _data: *const u8,
1117    _len: usize,
1118) -> *mut c_char {
1119    to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1120}
1121
1122#[cfg(not(feature = "pty"))]
1123#[no_mangle]
1124pub extern "C" fn aacode_terminal_read(
1125    _id: *const c_char,
1126    _cursor: u64,
1127    _max_bytes: usize,
1128) -> *mut c_char {
1129    to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1130}
1131
1132#[cfg(not(feature = "pty"))]
1133#[no_mangle]
1134pub extern "C" fn aacode_terminal_resize(
1135    _id: *const c_char,
1136    _cols: u16,
1137    _rows: u16,
1138) -> *mut c_char {
1139    to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1140}
1141
1142#[cfg(not(feature = "pty"))]
1143#[no_mangle]
1144pub extern "C" fn aacode_terminal_close(_id: *const c_char) -> *mut c_char {
1145    to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1146}
1147
1148#[cfg(not(feature = "pty"))]
1149#[no_mangle]
1150pub extern "C" fn aacode_terminal_list() -> *mut c_char {
1151    to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    extern "C" fn capture_cb(line: *const c_char, userdata: *mut c_void) {
1159        if line.is_null() {
1160            return;
1161        }
1162        let l = unsafe { CStr::from_ptr(line).to_str().unwrap_or("").to_string() };
1163        let sink = userdata as *mut Mutex<Vec<String>>;
1164        if !sink.is_null() {
1165            unsafe {
1166                if let Ok(mut v) = (*sink).lock() {
1167                    v.push(l);
1168                }
1169            }
1170        }
1171    }
1172
1173    fn run_task(json: &str) -> (String, Vec<String>) {
1174        let c = CString::new(json).unwrap();
1175        let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
1176        let ud = &lines as *const _ as *mut c_void;
1177        let h = aacode_task_start(c.as_ptr(), Some(capture_cb), ud);
1178        let result = unsafe {
1179            let p = aacode_task_wait(h);
1180            let s = CStr::from_ptr(p).to_str().unwrap().to_string();
1181            aacode_free_string(p);
1182            aacode_task_free(h);
1183            s
1184        };
1185        let captured = lines.lock().unwrap().clone();
1186        (result, captured)
1187    }
1188
1189    #[test]
1190    fn run_task_bad_json() {
1191        let (out, events) = run_task("not json");
1192        assert!(out.contains("bad task_json"), "got: {out}");
1193        assert!(
1194            events
1195                .iter()
1196                .any(|l| l.contains(r#""type":"error""#) && l.contains("bad task_json")),
1197            "error event must be emitted: {events:?}"
1198        );
1199    }
1200
1201    #[test]
1202    fn run_task_missing_task() {
1203        let (out, events) = run_task(r#"{"project_path":"/tmp"}"#);
1204        assert!(out.contains("error"), "got: {out}");
1205        assert!(events.iter().any(|l| l.contains(r#""type":"error""#)));
1206    }
1207
1208    #[test]
1209    fn callback_receives_userdata_on_early_error() {
1210        use std::sync::atomic::AtomicUsize;
1211        static RECEIVED_UD: AtomicUsize = AtomicUsize::new(0);
1212        extern "C" fn ud_cb(_line: *const c_char, ud: *mut c_void) {
1213            RECEIVED_UD.store(ud as usize, Ordering::SeqCst);
1214        }
1215        let c = CString::new(r#"{"project_path":"/tmp"}"#).unwrap();
1216        let marker = 0xABCD1234usize as *mut c_void;
1217        let h = aacode_task_start(c.as_ptr(), Some(ud_cb), marker);
1218        let p = aacode_task_wait(h);
1219        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1220        aacode_free_string(p);
1221        aacode_task_free(h);
1222        assert!(s.contains("missing task"), "got: {s}");
1223        // The host's opaque userdata must be passed back verbatim to the callback.
1224        assert_eq!(RECEIVED_UD.load(Ordering::SeqCst), 0xABCD1234usize);
1225    }
1226
1227    #[test]
1228    fn start_with_null_callback_still_returns_terminal_result() {
1229        let c = CString::new("not json").unwrap();
1230        let h = aacode_task_start(c.as_ptr(), None, std::ptr::null_mut());
1231        let p = aacode_task_wait(h);
1232        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1233        aacode_free_string(p);
1234        aacode_task_free(h);
1235        assert!(s.contains("bad task_json"), "got: {s}");
1236    }
1237
1238    #[test]
1239    fn null_task_json() {
1240        let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
1241        let ud = &lines as *const _ as *mut c_void;
1242        let h = aacode_task_start(std::ptr::null(), Some(capture_cb), ud);
1243        let result = unsafe {
1244            let p = aacode_task_wait(h);
1245            let s = CStr::from_ptr(p).to_str().unwrap().to_string();
1246            aacode_free_string(p);
1247            aacode_task_free(h);
1248            s
1249        };
1250        assert!(result.contains("null task_json"));
1251    }
1252
1253    #[test]
1254    fn wait_null_handle_is_safe() {
1255        let p = aacode_task_wait(std::ptr::null_mut());
1256        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1257        aacode_free_string(p);
1258        assert!(s.contains("error"));
1259    }
1260
1261    #[test]
1262    fn cancel_null_handle_is_safe() {
1263        aacode_task_cancel(std::ptr::null_mut());
1264    }
1265
1266    #[test]
1267    fn free_null_is_safe() {
1268        aacode_task_free(std::ptr::null_mut());
1269        aacode_free_string(std::ptr::null_mut());
1270    }
1271
1272    #[test]
1273    fn session_guard_rejects_duplicate() {
1274        let g1 = SessionGuard::try_acquire("p::s1".to_string());
1275        assert!(g1.is_some());
1276        let g2 = SessionGuard::try_acquire("p::s1".to_string());
1277        assert!(g2.is_none(), "duplicate session must be rejected");
1278        drop(g1);
1279        let g3 = SessionGuard::try_acquire("p::s1".to_string());
1280        assert!(g3.is_some(), "released session must be reacquirable");
1281    }
1282
1283    /// Cancelling a task must release its session guard promptly (abort), so a
1284    /// new message on the same session is not rejected with "already has a task
1285    /// running".
1286    #[test]
1287    fn cancel_aborts_task_and_releases_guard() {
1288        let key = format!("/tmp/p::cancel_{}", uuid::Uuid::new_v4().simple());
1289        let (tx, rx) = mpsc::channel::<String>();
1290        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
1291        let cancel = Arc::new(AtomicBool::new(false));
1292        let cancel2 = cancel.clone();
1293        let key2 = key.clone();
1294        let join = TOKIO_RT.spawn(async move {
1295            let _g = SessionGuard::try_acquire(key2).unwrap();
1296            let _ = ready_tx.send(());
1297            // A step that never checks the cancel flag (like a long tool call).
1298            futures::future::pending::<()>().await;
1299        });
1300        ready_rx
1301            .recv_timeout(std::time::Duration::from_secs(2))
1302            .expect("task should acquire the guard");
1303        assert!(
1304            SessionGuard::try_acquire(key.clone()).is_none(),
1305            "guard held while running"
1306        );
1307
1308        let handle = Box::into_raw(Box::new(AacodeTask::new(
1309            cancel2,
1310            rx,
1311            tx,
1312            Some(join),
1313            None,
1314            None,
1315        )));
1316        aacode_task_cancel(handle);
1317        std::thread::sleep(std::time::Duration::from_millis(300));
1318        assert!(
1319            SessionGuard::try_acquire(key.clone()).is_some(),
1320            "guard must be released after cancel"
1321        );
1322        aacode_task_free(handle);
1323    }
1324
1325    /// Cancel must release the session **synchronously** (not just when the
1326    /// aborted future is dropped), so the next message can start immediately
1327    /// even if the old task is stuck in a non-awaiting section.
1328    #[test]
1329    fn cancel_releases_session_synchronously() {
1330        let key = format!("/tmp/p::sync_{}", uuid::Uuid::new_v4().simple());
1331        let (tx, rx) = mpsc::channel::<String>();
1332        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
1333        let cancel = Arc::new(AtomicBool::new(false));
1334        let key2 = key.clone();
1335        let join = TOKIO_RT.spawn(async move {
1336            let _g = SessionGuard::try_acquire(key2).unwrap();
1337            let _ = ready_tx.send(());
1338            // Busy-wait without awaiting: `abort()` cannot drop this future
1339            // until it yields, so only a synchronous release can free the key.
1340            let end = std::time::Instant::now() + std::time::Duration::from_millis(1000);
1341            while std::time::Instant::now() < end {
1342                std::hint::spin_loop();
1343            }
1344        });
1345        ready_rx
1346            .recv_timeout(std::time::Duration::from_secs(2))
1347            .expect("task should acquire the guard");
1348        assert!(SessionGuard::try_acquire(key.clone()).is_none());
1349
1350        let handle = Box::into_raw(Box::new(AacodeTask::new(
1351            cancel,
1352            rx,
1353            tx,
1354            Some(join),
1355            Some(key.clone()),
1356            None,
1357        )));
1358        aacode_task_cancel(handle);
1359        // No sleep: the key must already be free.
1360        assert!(
1361            SessionGuard::try_acquire(key.clone()).is_some(),
1362            "cancel must release the session synchronously"
1363        );
1364        aacode_task_free(handle);
1365    }
1366
1367    #[test]
1368    fn concurrent_tasks_same_session_rejected() {
1369        let _hold = SessionGuard::try_acquire("/tmp/proj::sess_dup".to_string()).unwrap();
1370        let (out, _events) = run_task(
1371            r#"{"task":"do x","project_path":"/tmp/proj","session_id":"sess_dup","model":{"name":"m","api_key":"k"}}"#,
1372        );
1373        assert!(out.contains("already has a task running"), "got: {out}");
1374    }
1375
1376    #[test]
1377    fn list_sessions_ffi() {
1378        let d = std::env::temp_dir().join(format!("ffi_ls_{}", uuid::Uuid::new_v4().simple()));
1379        std::fs::create_dir_all(&d).unwrap();
1380        {
1381            let mut sm = SessionManager::new(&d);
1382            sm.create_session("task", None).unwrap();
1383        }
1384        let c = CString::new(d.to_string_lossy().to_string()).unwrap();
1385        let ptr = aacode_list_sessions(c.as_ptr());
1386        let s = unsafe { CStr::from_ptr(ptr).to_str().unwrap().to_string() };
1387        aacode_free_string(ptr);
1388        assert!(s.contains("\"success\":true"));
1389    }
1390
1391    // ── resolve_shell_root (skills user_dir root widening) ─────────
1392
1393    fn mkdirs(p: &std::path::Path) -> String {
1394        std::fs::create_dir_all(p).unwrap();
1395        p.to_string_lossy().to_string()
1396    }
1397
1398    fn tmp_root(tag: &str) -> std::path::PathBuf {
1399        std::env::temp_dir().join(format!("ffi_root_{tag}_{}", uuid::Uuid::new_v4().simple()))
1400    }
1401
1402    #[test]
1403    fn no_skills_config_keeps_project_jail() {
1404        let base = tmp_root("nocfg");
1405        let sb = mkdirs(&base.join("sb"));
1406        let pp = mkdirs(&base.join("sb").join("proj"));
1407        let (root, rel) = resolve_shell_root(&sb, &pp, None);
1408        assert_eq!(root, pp);
1409        assert!(rel.is_none());
1410    }
1411
1412    #[test]
1413    fn project_inside_sandbox_widens_and_positions_cwd() {
1414        let base = tmp_root("inside");
1415        let sb = mkdirs(&base.join("sb"));
1416        let sk = mkdirs(&base.join("sb").join("skills"));
1417        let pp = mkdirs(&base.join("sb").join("a").join("b"));
1418        let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1419        assert_eq!(root, sb);
1420        let rel = rel.expect("must reposition cwd");
1421        assert_eq!(
1422            std::path::PathBuf::from(rel),
1423            std::path::PathBuf::from("a").join("b")
1424        );
1425    }
1426
1427    #[test]
1428    fn project_equal_to_sandbox_user_dir_inside_no_widening() {
1429        let base = tmp_root("equal_in");
1430        let sb = mkdirs(&base.join("sb"));
1431        let sk = mkdirs(&base.join("sb").join("skills"));
1432        let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
1433        assert_eq!(root, sb);
1434        assert!(rel.is_none());
1435    }
1436
1437    #[test]
1438    fn project_equal_to_sandbox_user_dir_outside_widens() {
1439        let base = tmp_root("equal_out");
1440        let sb = mkdirs(&base.join("sb"));
1441        let sk = mkdirs(&base.join("skills"));
1442        let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
1443        let base_c = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
1444        assert_eq!(root, base_c.to_string_lossy());
1445        assert_eq!(rel.as_deref(), Some("sb"));
1446    }
1447
1448    #[test]
1449    fn sibling_prefix_dir_is_not_inside_sandbox() {
1450        let base = tmp_root("sibling");
1451        let sb = mkdirs(&base.join("sb"));
1452        let pp = mkdirs(&base.join("sb2").join("proj"));
1453        let sk = mkdirs(&base.join("sb2").join("proj").join("skills"));
1454        let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1455        assert_eq!(root, pp, "sibling project must keep its own jail");
1456        assert!(rel.is_none());
1457    }
1458
1459    #[test]
1460    fn project_outside_sandbox_keeps_own_jail() {
1461        let base = tmp_root("outside");
1462        let sb = mkdirs(&base.join("sb"));
1463        let pp = mkdirs(&base.join("elsewhere").join("proj"));
1464        let sk = mkdirs(&base.join("elsewhere").join("proj").join("skills"));
1465        let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1466        assert_eq!(root, pp);
1467        assert!(rel.is_none());
1468    }
1469
1470    #[cfg(unix)]
1471    #[test]
1472    fn symlinked_project_path_still_recognized() {
1473        let base = tmp_root("symlink");
1474        let real_sb = mkdirs(&base.join("real_sb"));
1475        mkdirs(&base.join("real_sb").join("proj"));
1476        let sk = mkdirs(&base.join("real_sb").join("skills"));
1477        let link_sb = base.join("link_sb");
1478        std::os::unix::fs::symlink(&real_sb, &link_sb).unwrap();
1479        let pp_via_link = link_sb.join("proj").to_string_lossy().to_string();
1480        let (root, rel) = resolve_shell_root(&real_sb, &pp_via_link, Some(&sk));
1481        assert_eq!(root, real_sb, "symlinked project must widen to sandbox");
1482        assert_eq!(rel.as_deref(), Some("proj"));
1483    }
1484
1485    // ── SESSION_FFI store functions ──────────────────────────────────
1486
1487    fn call_str(f: impl FnOnce() -> *mut c_char) -> String {
1488        let p = f();
1489        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1490        aacode_free_string(p);
1491        s
1492    }
1493
1494    #[test]
1495    fn session_version_is_one() {
1496        assert_eq!(aacode_session_version(), SCHEMA_VERSION);
1497        assert_eq!(aacode_session_version(), 1);
1498    }
1499
1500    #[test]
1501    fn session_lifecycle_roundtrip() {
1502        let proj = mkdirs(&tmp_root("sess_life"));
1503        let pp = CString::new(proj).unwrap();
1504        let sid = CString::new("sess_a").unwrap();
1505
1506        // ensure (create)
1507        let title = CString::new("My Session").unwrap();
1508        let r = call_str(|| aacode_session_ensure(pp.as_ptr(), sid.as_ptr(), title.as_ptr()));
1509        assert!(r.contains("\"success\":true"), "ensure: {r}");
1510
1511        // append two messages
1512        let msgs = r#"[{"role":"user","content":"hello","timestamp":"1700000001"},{"role":"assistant","content":"hi","timestamp":"1700000002"}]"#;
1513        let mj = CString::new(msgs).unwrap();
1514        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1515        assert!(r.contains("\"success\":true"), "append: {r}");
1516
1517        // rename
1518        let new_title = CString::new("Renamed").unwrap();
1519        let r = call_str(|| aacode_session_rename(pp.as_ptr(), sid.as_ptr(), new_title.as_ptr()));
1520        assert!(r.contains("\"success\":true"), "rename: {r}");
1521
1522        // list
1523        let r = call_str(|| aacode_session_list(pp.as_ptr()));
1524        assert!(r.contains("\"success\":true"), "list: {r}");
1525        assert!(r.contains("sess_a"));
1526        assert!(r.contains("Renamed"));
1527
1528        // paginated messages
1529        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1530        assert!(r.contains("\"success\":true"), "messages: {r}");
1531        assert!(r.contains("\"total\":2"));
1532        assert!(r.contains("hello"));
1533
1534        // touch
1535        let r = call_str(|| aacode_session_touch(pp.as_ptr(), sid.as_ptr()));
1536        assert!(r.contains("\"success\":true"), "touch: {r}");
1537
1538        // delete (idempotent)
1539        let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
1540        assert!(r.contains("\"success\":true"), "delete: {r}");
1541        let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
1542        assert!(
1543            r.contains("\"success\":true"),
1544            "delete again (idempotent): {r}"
1545        );
1546    }
1547
1548    #[test]
1549    fn session_append_bad_json_errors() {
1550        let proj = mkdirs(&tmp_root("sess_bad"));
1551        let pp = CString::new(proj).unwrap();
1552        let sid = CString::new("s1").unwrap();
1553        let bad = CString::new("not json").unwrap();
1554        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), bad.as_ptr()));
1555        assert!(
1556            r.contains("\"success\":false"),
1557            "bad msgs_json must error: {r}"
1558        );
1559        assert!(r.contains("bad msgs_json"));
1560    }
1561
1562    #[test]
1563    fn session_messages_pagination() {
1564        let proj = mkdirs(&tmp_root("sess_page"));
1565        let pp = CString::new(proj.clone()).unwrap();
1566        let sid = CString::new("s1").unwrap();
1567        {
1568            let mut sm = SessionManager::new(std::path::Path::new(&proj));
1569            sm.ensure_session("s1", "").unwrap();
1570            for i in 0..5 {
1571                sm.append_session_messages(
1572                    "s1",
1573                    vec![SessionMessage {
1574                        role: "user".to_string(),
1575                        content: format!("m{i}"),
1576                        timestamp: format!("17000000{i:02}"),
1577                        tokens: 1,
1578                        tool_calls: None,
1579                        tool_call_id: None,
1580                        reasoning_content: None,
1581                    }],
1582                )
1583                .unwrap();
1584            }
1585        }
1586        // offset=0 limit=2 → newest two (m3, m4)
1587        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 2));
1588        assert!(r.contains("\"total\":5"), "total: {r}");
1589        assert!(
1590            r.contains("m4") && r.contains("m3") && !r.contains("m0"),
1591            "page0: {r}"
1592        );
1593        // offset=2 limit=2 → m1, m2
1594        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 2, 2));
1595        assert!(
1596            r.contains("m2") && r.contains("m1") && !r.contains("m3"),
1597            "page1: {r}"
1598        );
1599    }
1600
1601    #[test]
1602    fn session_messages_null_args_error() {
1603        let r = call_str(|| aacode_session_messages(std::ptr::null(), std::ptr::null(), 0, 50));
1604        assert!(r.contains("\"success\":false"), "null args: {r}");
1605    }
1606
1607    #[test]
1608    fn session_ffi_rejects_path_traversal_sid() {
1609        let proj = mkdirs(&tmp_root("sess_trav"));
1610        let pp = CString::new(proj).unwrap();
1611        let evil = CString::new("../escape").unwrap();
1612        let r = call_str(|| aacode_session_ensure(pp.as_ptr(), evil.as_ptr(), std::ptr::null()));
1613        assert!(
1614            r.contains("\"success\":false"),
1615            "path traversal must be rejected: {r}"
1616        );
1617        assert!(r.contains("invalid session_id"));
1618        let r = call_str(|| aacode_session_messages(pp.as_ptr(), evil.as_ptr(), 0, 50));
1619        assert!(r.contains("\"success\":false"));
1620        let r = call_str(|| aacode_session_delete(pp.as_ptr(), evil.as_ptr()));
1621        assert!(r.contains("\"success\":false"));
1622    }
1623
1624    #[test]
1625    fn session_messages_limit_zero_and_beyond_total() {
1626        let proj = mkdirs(&tmp_root("sess_l0"));
1627        let pp = CString::new(proj.clone()).unwrap();
1628        let sid = CString::new("s1").unwrap();
1629        {
1630            let mut sm = SessionManager::new(std::path::Path::new(&proj));
1631            sm.ensure_session("s1", "").unwrap();
1632            sm.append_session_messages(
1633                "s1",
1634                vec![SessionMessage {
1635                    role: "user".to_string(),
1636                    content: "hi".to_string(),
1637                    timestamp: "1".to_string(),
1638                    tokens: 1,
1639                    tool_calls: None,
1640                    tool_call_id: None,
1641                    reasoning_content: None,
1642                }],
1643            )
1644            .unwrap();
1645        }
1646        // limit=0 → no messages, total still reported.
1647        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 0));
1648        assert!(r.contains("\"total\":1"), "limit=0 total: {r}");
1649        assert!(
1650            !r.contains("\"hi\""),
1651            "limit=0 must return no messages: {r}"
1652        );
1653        // offset beyond total → empty.
1654        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 5, 10));
1655        assert!(r.contains("\"total\":1"));
1656        assert!(
1657            !r.contains("\"hi\""),
1658            "offset beyond total must be empty: {r}"
1659        );
1660    }
1661
1662    #[test]
1663    fn session_unicode_content_roundtrip() {
1664        let proj = mkdirs(&tmp_root("sess_uni"));
1665        let pp = CString::new(proj).unwrap();
1666        let sid = CString::new("s1").unwrap();
1667        let msgs = r#"[{"role":"user","content":"你好,世界 🌍","timestamp":"1700000001"}]"#;
1668        let mj = CString::new(msgs).unwrap();
1669        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1670        assert!(r.contains("\"success\":true"), "append unicode: {r}");
1671        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1672        assert!(r.contains("你好,世界"), "unicode content preserved: {r}");
1673    }
1674
1675    #[test]
1676    fn session_append_ignores_segments_field() {
1677        // A host may still send legacy `segments`; it must be accepted (success)
1678        // and dropped — never re-persisted or read back.
1679        let proj = mkdirs(&tmp_root("sess_segs"));
1680        let pp = CString::new(proj).unwrap();
1681        let sid = CString::new("s1").unwrap();
1682        let msgs = r#"[
1683            {"role":"assistant","content":"ok","timestamp":"1700000001",
1684             "segments":[
1685               {"type":"thinking","content":"think","created_at":"1700000001.001"},
1686               {"type":"action","name":"run_shell","content":"{}","created_at":"1700000001.002"}
1687             ]}
1688        ]"#;
1689        let mj = CString::new(msgs).unwrap();
1690        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1691        assert!(r.contains("\"success\":true"), "append: {r}");
1692
1693        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1694        assert!(r.contains("\"ok\""), "content preserved: {r}");
1695        assert!(
1696            !r.contains("segments"),
1697            "legacy segments must be dropped, not persisted: {r}"
1698        );
1699    }
1700
1701    #[test]
1702    fn session_append_without_segments_still_works() {
1703        let proj = mkdirs(&tmp_root("sess_noseg"));
1704        let pp = CString::new(proj).unwrap();
1705        let sid = CString::new("s1").unwrap();
1706        let msgs = r#"[{"role":"user","content":"hi","timestamp":"1700000001"}]"#;
1707        let mj = CString::new(msgs).unwrap();
1708        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1709        assert!(
1710            r.contains("\"success\":true"),
1711            "append without segments: {r}"
1712        );
1713        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1714        assert!(
1715            r.contains("\"hi\""),
1716            "message without segments still read: {r}"
1717        );
1718        assert!(
1719            !r.contains("segments"),
1720            "segments are never emitted in the wire: {r}"
1721        );
1722    }
1723
1724    #[test]
1725    fn session_list_empty_project() {
1726        let proj = mkdirs(&tmp_root("sess_empty"));
1727        let pp = CString::new(proj).unwrap();
1728        let r = call_str(|| aacode_session_list(pp.as_ptr()));
1729        assert!(r.contains("\"success\":true"));
1730        assert!(r.contains("\"sessions\":[]"), "empty project: {r}");
1731    }
1732}
1733
1734#[cfg(test)]
1735mod surface_ffi_tests {
1736    use super::*;
1737
1738    #[test]
1739    fn register_surface_ops_rejects_null() {
1740        // Null table is always rejected (with or without the `browser` feature).
1741        assert_eq!(
1742            unsafe { aacode_browser_register_surface_ops(std::ptr::null()) },
1743            -1
1744        );
1745    }
1746}
1747
1748#[cfg(all(test, feature = "pty", unix))]
1749mod pty_ffi_tests {
1750    use super::*;
1751    use base64::Engine as _;
1752
1753    fn take(ptr: *mut c_char) -> serde_json::Value {
1754        assert!(!ptr.is_null());
1755        let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string();
1756        aacode_free_string(ptr);
1757        serde_json::from_str(&s).unwrap()
1758    }
1759
1760    #[test]
1761    fn terminal_ffi_open_write_read_list_close() {
1762        let shell = CString::new("/bin/sh").unwrap();
1763        let cwd = CString::new(".").unwrap();
1764        let open = take(aacode_terminal_open(shell.as_ptr(), cwd.as_ptr(), 80, 24));
1765        assert_eq!(open["ok"], true, "{open:?}");
1766        let id = open["id"].as_str().unwrap().to_string();
1767        let idc = CString::new(id.clone()).unwrap();
1768
1769        let cmd = b"printf 'FFI_OK\\n'\n";
1770        let w = take(aacode_terminal_write(idc.as_ptr(), cmd.as_ptr(), cmd.len()));
1771        assert_eq!(w["ok"], true, "{w:?}");
1772
1773        let mut cursor = 0u64;
1774        let mut all = String::new();
1775        for _ in 0..200 {
1776            let r = take(aacode_terminal_read(idc.as_ptr(), cursor, 4096));
1777            if !r["ok"].as_bool().unwrap_or(false) {
1778                break;
1779            }
1780            cursor = r["cursor"].as_u64().unwrap_or(cursor);
1781            if let Some(b64) = r["data_b64"].as_str() {
1782                let bytes = base64::engine::general_purpose::STANDARD
1783                    .decode(b64)
1784                    .unwrap();
1785                all.push_str(&String::from_utf8_lossy(&bytes));
1786            }
1787            if all.contains("FFI_OK") {
1788                break;
1789            }
1790            std::thread::sleep(std::time::Duration::from_millis(20));
1791        }
1792        assert!(all.contains("FFI_OK"), "output: {all:?}");
1793
1794        let list = take(aacode_terminal_list());
1795        assert!(list["sessions"]
1796            .as_array()
1797            .unwrap()
1798            .iter()
1799            .any(|s| s["id"].as_str() == Some(id.as_str())));
1800
1801        let resized = take(aacode_terminal_resize(idc.as_ptr(), 100, 40));
1802        assert_eq!(resized["ok"], true);
1803        let closed = take(aacode_terminal_close(idc.as_ptr()));
1804        assert_eq!(closed["ok"], true);
1805    }
1806}