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::{Arc, Mutex, OnceLock};
39use std::sync::mpsc;
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}
51
52/// Wrapper making the raw `userdata` pointer safe to move into the tokio task
53/// (the host guarantees the pointed-to context outlives the task).
54struct SendUserData(*mut c_void);
55// SAFETY: the host contract requires `userdata` to remain valid until the task
56// completes and `aacode_task_free` is called. It is only passed back verbatim
57// to the host callback.
58unsafe impl Send for SendUserData {}
59unsafe impl Sync for SendUserData {}
60
61impl SendUserData {
62    /// Raw pointer access via a method so closures capture the whole wrapper
63    /// (and its `Send`/`Sync` impls) rather than the bare `*mut c_void` field.
64    #[inline]
65    fn ptr(&self) -> *mut c_void {
66        self.0
67    }
68}
69
70/// In-flight session ids (project_path + session_id). Two concurrent tasks on
71/// the SAME session would corrupt the session file (last-writer-wins), so the
72/// second submission is rejected up front.
73static RUNNING_SESSIONS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
74
75fn running_sessions() -> &'static Mutex<std::collections::HashSet<String>> {
76    RUNNING_SESSIONS.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
77}
78
79/// RAII guard marking a session as busy. `try_acquire` returns None when the
80/// session already has an in-flight task.
81struct SessionGuard {
82    key: String,
83}
84
85impl SessionGuard {
86    fn try_acquire(key: String) -> Option<Self> {
87        let mut set = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
88        if set.contains(&key) {
89            return None;
90        }
91        set.insert(key.clone());
92        Some(SessionGuard { key })
93    }
94}
95
96impl Drop for SessionGuard {
97    fn drop(&mut self) {
98        let mut set = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
99        set.remove(&self.key);
100    }
101}
102
103/// Convert a C string pointer to a Rust &str safely.
104unsafe fn cstr<'a>(ptr: *const c_char) -> Option<&'a str> {
105    if ptr.is_null() {
106        return None;
107    }
108    CStr::from_ptr(ptr).to_str().ok()
109}
110
111/// Allocate a C string from a Rust String (caller frees via aacode_free_string).
112fn to_c_string(s: String) -> *mut c_char {
113    CString::new(s)
114        .unwrap_or_else(|_| CString::new("").unwrap())
115        .into_raw()
116}
117
118/// Decide the shell sandbox root for the SDK (mobile/embedded host) path.
119/// Returns `(shell_root, cd_rel)`: the VFS root to init fastshell with, and
120/// an optional sandbox-relative path to `cd` into right after init.
121///
122/// Default: the shell is confined to the project. When the host configured a
123/// user skills directory (`config.skills.user_dir`) the agent must be able to
124/// manage skills via run_shell — those may live OUTSIDE the project — so the
125/// shell root is widened to the minimal common ancestor of project and
126/// user_dir. Hosts that don't pass skills.user_dir keep the old
127/// strictly-project-confined behavior.
128///
129/// Widening uses a three-step cascade (first match wins):
130/// 1. Project is a proper subdirectory of sandbox → widen to sandbox.
131/// 2. User dir is already inside project → no widening needed.
132/// 3. Walk project ancestors to find the closest one containing user dir.
133///
134/// Paths are canonicalized and compared **component-wise** (`Path::strip_prefix`),
135/// not by string prefix:
136///  * Android hands out `/data/user/0/<pkg>/…` while the canonical form is
137///    `/data/data/<pkg>/…` (symlink); iOS/macOS have `/var` → `/private/var`.
138///  * A sibling directory sharing a name prefix (`/sb` vs `/sb2/proj`) must
139///    NOT be treated as inside the sandbox — a string check would wrongly
140///    widen the root and then fail the `cd`.
141fn resolve_shell_root(
142    sandbox: &str,
143    project: &str,
144    user_dir: Option<&str>,
145) -> (String, Option<String>) {
146    let Some(user_dir) = user_dir else {
147        return (project.to_string(), None);
148    };
149    let canon = |s: &str| {
150        std::fs::canonicalize(s).unwrap_or_else(|_| std::path::PathBuf::from(s))
151    };
152    let sandbox_c = canon(sandbox);
153    let project_c = canon(project);
154    let user_dir_c = canon(user_dir);
155
156    // Step 1: project is a proper subdirectory of sandbox → widen to sandbox.
157    match project_c.strip_prefix(&sandbox_c) {
158        Ok(rel) if !rel.as_os_str().is_empty() => {
159            return (sandbox.to_string(), Some(rel.to_string_lossy().to_string()));
160        }
161        _ => {}
162    }
163
164    // Step 2: user_dir is already inside project → no widening needed.
165    if user_dir_c.strip_prefix(&project_c).is_ok() {
166        return (project.to_string(), None);
167    }
168
169    // Step 3: walk project ancestors to find the closest common ancestor
170    //         that also contains user_dir. Stop before the filesystem root.
171    //
172    // Example (iOS): project = /a/b/projects/Test3, user_dir = /a/b/skills
173    //   ancestors of project:  /a/b/projects  → user_dir not inside ✗
174    //                          /a/b            → user_dir inside      ✓  ← widen here
175    //   Result: shell_root = /a/b, cd_rel = projects/Test3
176    for ancestor in project_c.ancestors().skip(1) {
177        if ancestor.parent().is_none() {
178            break;
179        }
180        if user_dir_c.strip_prefix(ancestor).is_ok() {
181            if let Ok(rel) = project_c.strip_prefix(ancestor) {
182                if !rel.as_os_str().is_empty() {
183                    return (ancestor.to_string_lossy().to_string(), Some(rel.to_string_lossy().to_string()));
184                }
185            }
186        }
187    }
188
189    (project.to_string(), None)
190}
191
192/// Build the agent runtime for the parsed config + project path. Returns an
193/// error string suitable for an `error` event / terminal JSON.
194fn build_runtime(
195    config: &AgentConfig,
196    project_path: &str,
197) -> Result<AgentRuntime, String> {
198    if let Some(sdk) = fastshell::sdk::try_get_sdk_instance() {
199        let sandbox = {
200            let guard = sdk.lock().unwrap_or_else(|e| e.into_inner());
201            guard.vfs_root()
202        };
203        let pp = if project_path == "." { sandbox.clone() } else { project_path.to_string() };
204        // Shell sandbox root + optional cwd reposition (see resolve_shell_root).
205        let (shell_root, cd_rel) =
206            resolve_shell_root(&sandbox, &pp, config.skills.user_dir.as_deref());
207        // Compute a VFS-internal skills path (relative to shell root) so
208        // skill prompts can use short, platform-independent paths like
209        // `/skills` instead of long absolute physical paths.
210        let mut cfg = config.clone();
211        if let Some(ref user_dir) = cfg.skills.user_dir {
212            if let Ok(rel) = std::path::Path::new(user_dir).strip_prefix(&shell_root) {
213                if !rel.as_os_str().is_empty() {
214                    cfg.skills.vfs_skills_dir = Some(format!("/{}", rel.to_string_lossy()));
215                }
216            }
217        }
218        let fs_arc: Arc<Mutex<fastshell::Fastshell>> =
219            Arc::new(Mutex::new(fastshell::Fastshell::new()));
220        {
221            let mut ours = fs_arc.lock().unwrap();
222            let mut fscfg = fastshell::Config::default();
223            fscfg.sandbox_path = shell_root.clone();
224            fscfg.python_enabled = true;
225            fscfg.allow_subprocess = false; // mobile sandbox: no subprocess spawn
226            fscfg.network_ask_permission = false;
227            if let Err(e) = ours.init(fscfg) {
228                return Err(e.to_string());
229            }
230            if let Some(rel) = cd_rel {
231                // Position the persistent shell session inside the project so
232                // relative paths behave exactly as before the root widening.
233                let r = ours.execute(&format!("cd '{}'", rel.replace('\'', "'\\''")));
234                if r.exit_code != 0 {
235                    return Err(format!("cd to project failed: {}", r.stderr));
236                }
237            }
238        }
239        Ok(AgentRuntime::with_fastshell(cfg, std::path::PathBuf::from(pp), fs_arc))
240    } else {
241        AgentRuntime::init(config.clone(), std::path::PathBuf::from(project_path))
242            .map_err(|e| e.to_string())
243    }
244}
245
246/// Start a task described by `task_json`. Non-blocking; events stream through
247/// `cb(line, userdata)`. Returns an opaque handle (NULL on catastrophic OOM).
248#[no_mangle]
249pub extern "C" fn aacode_task_start(
250    task_json: *const c_char,
251    cb: Option<StreamCallback>,
252    userdata: *mut c_void,
253) -> *mut AacodeTask {
254    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
255    let (tx, rx) = mpsc::channel::<String>();
256    let cancel = Arc::new(AtomicBool::new(false));
257
258    let ud = SendUserData(userdata);
259    let emit = move |line: &str| {
260        if let Some(cb) = cb {
261            if let Ok(cs) = CString::new(line) {
262                cb(cs.as_ptr(), ud.ptr());
263            }
264        }
265    };
266
267    let input = match unsafe { cstr(task_json) } {
268        Some(s) => s.to_string(),
269        None => {
270            let msg = "null task_json";
271            emit(&json!({"type": "error", "message": msg}).to_string());
272            let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
273            return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
274        }
275    };
276
277    let v: serde_json::Value = match serde_json::from_str(&input) {
278        Ok(v) => v,
279        Err(e) => {
280            let msg = format!("bad task_json: {e}");
281            emit(&json!({"type": "error", "message": msg}).to_string());
282            let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
283            return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
284        }
285    };
286    let task = v.get("task").and_then(|x| x.as_str()).unwrap_or("").to_string();
287    if task.is_empty() {
288        let msg = "missing task";
289        emit(&json!({"type": "error", "message": msg}).to_string());
290        let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
291        return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
292    }
293    let project_path = v.get("project_path").and_then(|x| x.as_str()).unwrap_or(".").to_string();
294    let session_id = v.get("session_id").and_then(|x| x.as_str()).map(|s| s.to_string());
295
296    // Reject a second concurrent task on the same session (would corrupt the
297    // session file). New sessions (no session_id) are always allowed.
298    let session_guard = match &session_id {
299        Some(sid) => {
300            let key = format!("{project_path}::{sid}");
301            match SessionGuard::try_acquire(key) {
302                Some(g) => Some(g),
303                None => {
304                    let msg = format!("session '{sid}' already has a task running");
305                    emit(&json!({"type": "error", "message": msg}).to_string());
306                    let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
307                    return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
308                }
309            }
310        }
311        None => None,
312    };
313
314    let mut config: AgentConfig = serde_json::from_value(v).unwrap_or_default();
315    config.apply_env();
316
317    let rt = match build_runtime(&config, &project_path) {
318        Ok(rt) => rt,
319        Err(e) => {
320            emit(&json!({"type": "error", "message": e}).to_string());
321            let _ = tx.send(json!({"status": "error", "error": e}).to_string());
322            return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
323        }
324    };
325
326    let cancel2 = cancel.clone();
327    let _join = TOKIO_RT.spawn(async move {
328        // Hold the session guard for the duration of the task.
329        let _guard = session_guard;
330        let result = rt
331            .run_task(&task, session_id.as_deref(), &CallbackSink::new(Box::new(emit)), &cancel2)
332            .await;
333        let json = match result {
334            Ok(res) => res.to_result_json().to_string(),
335            Err(e) => json!({"status": "error", "error": e.to_string()}).to_string(),
336        };
337        let _ = tx.send(json);
338    });
339
340    Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }))
341}
342
343/// Block until the task finishes; return the terminal JSON result string
344/// (caller frees with `aacode_free_string`).
345#[no_mangle]
346pub extern "C" fn aacode_task_wait(handle: *mut AacodeTask) -> *mut c_char {
347    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
348    if handle.is_null() {
349        return to_c_string(json!({"status": "error", "error": "null handle"}).to_string());
350    }
351    let h = unsafe { &*handle };
352    let result = h.result_rx.recv().unwrap_or_else(|_| {
353        json!({"status": "error", "error": "task was dropped before completion"}).to_string()
354    });
355    to_c_string(result)
356}
357
358/// Signal cancellation. Non-blocking; the running task observes it async.
359#[no_mangle]
360pub extern "C" fn aacode_task_cancel(handle: *mut AacodeTask) {
361    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
362    if handle.is_null() {
363        return;
364    }
365    let h = unsafe { &*handle };
366    h.cancel.store(true, Ordering::SeqCst);
367}
368
369/// Free a finished handle.
370#[no_mangle]
371pub extern "C" fn aacode_task_free(handle: *mut AacodeTask) {
372    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
373    if handle.is_null() {
374        return;
375    }
376    unsafe {
377        drop(Box::from_raw(handle));
378    }
379}
380
381/// Validate an API key. `config_json` should contain a `model` object (or the
382/// flat model fields). Returns `{"valid":bool,"error":...}`.
383#[no_mangle]
384pub extern "C" fn aacode_validate_api_key(config_json: *const c_char) -> *mut c_char {
385    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
386    let input = match unsafe { cstr(config_json) } {
387        Some(s) => s.to_string(),
388        None => return to_c_string(json!({"valid": false, "error": "null config"}).to_string()),
389    };
390    let mut config: AgentConfig = serde_json::from_str(&input).unwrap_or_default();
391    config.apply_env();
392    let client = crate::llm::build_client(&config.model);
393    let handle = TOKIO_RT.handle().clone();
394    let out = std::thread::scope(|s| {
395        s.spawn(move || match handle.block_on(async { client.validate().await }) {
396            Ok(()) => json!({"valid": true}),
397            Err(e) => json!({"valid": false, "error": e.to_string()}),
398        })
399        .join()
400        .unwrap()
401    });
402    to_c_string(out.to_string())
403}
404
405/// List sessions for a project path. Returns a JSON array.
406#[no_mangle]
407pub extern "C" fn aacode_list_sessions(project_path: *const c_char) -> *mut c_char {
408    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
409    let pp = match unsafe { cstr(project_path) } {
410        Some(s) => s.to_string(),
411        None => return to_c_string(json!({"success": false, "error": "null path"}).to_string()),
412    };
413    let sm = SessionManager::new(std::path::Path::new(&pp));
414    let sessions: Vec<serde_json::Value> = sm
415        .list_sessions()
416        .into_iter()
417        .map(|s| {
418            json!({
419                "session_id": s.session_id,
420                "title": s.title,
421                "created_at": s.created_at,
422                "last_activity": s.last_activity,
423                "total_messages": s.total_messages,
424                "status": s.status,
425            })
426        })
427        .collect();
428    to_c_string(json!({"success": true, "sessions": sessions}).to_string())
429}
430
431/// Get the messages of a session. Returns a JSON array of {role, content, ...}.
432#[no_mangle]
433pub extern "C" fn aacode_get_session_messages(
434    project_path: *const c_char,
435    session_id: *const c_char,
436) -> *mut c_char {
437    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
438    let pp = unsafe { cstr(project_path) }.unwrap_or("").to_string();
439    let sid = unsafe { cstr(session_id) }.unwrap_or("").to_string();
440    let sm = SessionManager::new(std::path::Path::new(&pp));
441    let msgs = sm.read_session_messages(&sid);
442    let arr: Vec<serde_json::Value> = msgs
443        .iter()
444        .map(|m| {
445            json!({
446                "role": m.role,
447                "content": m.content,
448                "timestamp": m.timestamp,
449                "tool_calls": m.tool_calls,
450                "tool_call_id": m.tool_call_id,
451                "reasoning_content": m.reasoning_content,
452                "segments": m.segments,
453            })
454        })
455        .collect();
456    to_c_string(json!({"success": true, "messages": arr}).to_string())
457}
458
459// ── Session store FFI (see SESSION_FFI.md) ─────────────────────────
460
461/// Serializes all session-store write FFI calls so concurrent callers can't
462/// interleave read-modify-write on the shared index/session files (in-process
463/// serialization; the agent's own writes are not under this lock).
464static SESSION_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
465
466/// Serialize one persisted message into the SESSION_FFI wire shape.
467fn message_to_json(m: &SessionMessage) -> serde_json::Value {
468    json!({
469        "role": m.role,
470        "content": m.content,
471        "timestamp": m.timestamp,
472        "tokens": m.tokens,
473        "tool_calls": m.tool_calls,
474        "tool_call_id": m.tool_call_id,
475        "reasoning_content": m.reasoning_content,
476        "segments": m.segments,
477    })
478}
479
480/// Build the `{"success": false, "error": ...}` failure envelope.
481fn session_err(msg: &str) -> *mut c_char {
482    to_c_string(json!({"success": false, "error": msg}).to_string())
483}
484
485/// Parse `project` + `session_id` C strings into owned Strings, validating the
486/// session id so it cannot escape the sessions directory.
487fn session_id_args(
488    project_path: *const c_char,
489    session_id: *const c_char,
490) -> Result<(String, String), String> {
491    let pp = unsafe { cstr(project_path) }
492        .ok_or_else(|| "null project_path".to_string())?
493        .to_string();
494    let sid = unsafe { cstr(session_id) }
495        .ok_or_else(|| "null session_id".to_string())?
496        .to_string();
497    if !valid_session_id(&sid) {
498        return Err("invalid session_id".to_string());
499    }
500    Ok((pp, sid))
501}
502
503/// Current on-disk session schema version (see SESSION_FFI.md §6).
504#[no_mangle]
505pub extern "C" fn aacode_session_version() -> u32 {
506    SCHEMA_VERSION
507}
508
509/// List sessions for a project (SESSION_FFI contract). Returns a JSON array.
510#[no_mangle]
511pub extern "C" fn aacode_session_list(project_path: *const c_char) -> *mut c_char {
512    let pp = match unsafe { cstr(project_path) } {
513        Some(s) => s.to_string(),
514        None => return session_err("null project_path"),
515    };
516    let sm = SessionManager::new(std::path::Path::new(&pp));
517    let sessions: Vec<serde_json::Value> = sm
518        .list_sessions()
519        .into_iter()
520        .map(|s| {
521            json!({
522                "session_id": s.session_id,
523                "title": s.title,
524                "created_at": s.created_at,
525                "last_activity": s.last_activity,
526                "total_messages": s.total_messages,
527                "total_tokens": s.total_tokens,
528                "status": s.status,
529            })
530        })
531        .collect();
532    to_c_string(json!({"success": true, "sessions": sessions}).to_string())
533}
534
535/// Paginated session messages (SESSION_FFI contract).
536#[no_mangle]
537pub extern "C" fn aacode_session_messages(
538    project_path: *const c_char,
539    session_id: *const c_char,
540    offset: u32,
541    limit: u32,
542) -> *mut c_char {
543    let (pp, sid) = match session_id_args(project_path, session_id) {
544        Ok(v) => v,
545        Err(e) => return session_err(&e),
546    };
547    let sm = SessionManager::new(std::path::Path::new(&pp));
548    let all = sm.read_session_messages(&sid);
549    let total = all.len();
550    let offset = offset as usize;
551    let limit = limit as usize;
552    let end = total.saturating_sub(offset);
553    let start = end.saturating_sub(limit);
554    let slice: Vec<serde_json::Value> = if start < end {
555        all[start..end].iter().map(message_to_json).collect()
556    } else {
557        Vec::new()
558    };
559    to_c_string(
560        json!({
561            "success": true,
562            "total": total,
563            "offset": offset,
564            "limit": limit,
565            "messages": slice,
566        })
567        .to_string(),
568    )
569}
570
571/// Idempotent create-or-touch a session (SESSION_FFI contract).
572#[no_mangle]
573pub extern "C" fn aacode_session_ensure(
574    project_path: *const c_char,
575    session_id: *const c_char,
576    title: *const c_char,
577) -> *mut c_char {
578    let (pp, sid) = match session_id_args(project_path, session_id) {
579        Ok(v) => v,
580        Err(e) => return session_err(&e),
581    };
582    let title = unsafe { cstr(title) }.unwrap_or("").to_string();
583    let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
584    let mut sm = SessionManager::new(std::path::Path::new(&pp));
585    match sm.ensure_session(&sid, &title) {
586        Ok(()) => to_c_string(json!({"success": true}).to_string()),
587        Err(e) => session_err(&e.to_string()),
588    }
589}
590
591/// Set an explicit title on a session (SESSION_FFI contract).
592#[no_mangle]
593pub extern "C" fn aacode_session_rename(
594    project_path: *const c_char,
595    session_id: *const c_char,
596    title: *const c_char,
597) -> *mut c_char {
598    let (pp, sid) = match session_id_args(project_path, session_id) {
599        Ok(v) => v,
600        Err(e) => return session_err(&e),
601    };
602    let title = unsafe { cstr(title) }.unwrap_or("").to_string();
603    let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
604    let mut sm = SessionManager::new(std::path::Path::new(&pp));
605    match sm.rename_session(&sid, &title) {
606        Ok(()) => to_c_string(json!({"success": true}).to_string()),
607        Err(e) => session_err(&e.to_string()),
608    }
609}
610
611/// Bump `last_activity` of a session (SESSION_FFI contract).
612#[no_mangle]
613pub extern "C" fn aacode_session_touch(
614    project_path: *const c_char,
615    session_id: *const c_char,
616) -> *mut c_char {
617    let (pp, sid) = match session_id_args(project_path, session_id) {
618        Ok(v) => v,
619        Err(e) => return session_err(&e),
620    };
621    let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
622    let mut sm = SessionManager::new(std::path::Path::new(&pp));
623    match sm.touch_session(&sid) {
624        Ok(()) => to_c_string(json!({"success": true}).to_string()),
625        Err(e) => session_err(&e.to_string()),
626    }
627}
628
629/// Delete a session (idempotent; SESSION_FFI contract).
630#[no_mangle]
631pub extern "C" fn aacode_session_delete(
632    project_path: *const c_char,
633    session_id: *const c_char,
634) -> *mut c_char {
635    let (pp, sid) = match session_id_args(project_path, session_id) {
636        Ok(v) => v,
637        Err(e) => return session_err(&e),
638    };
639    let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
640    let mut sm = SessionManager::new(std::path::Path::new(&pp));
641    match sm.delete_session(&sid) {
642        Ok(_) => to_c_string(json!({"success": true}).to_string()),
643        Err(e) => session_err(&e.to_string()),
644    }
645}
646
647/// Append messages to a session (SESSION_FFI contract).
648#[no_mangle]
649pub extern "C" fn aacode_session_append(
650    project_path: *const c_char,
651    session_id: *const c_char,
652    msgs_json: *const c_char,
653) -> *mut c_char {
654    let (pp, sid) = match session_id_args(project_path, session_id) {
655        Ok(v) => v,
656        Err(e) => return session_err(&e),
657    };
658    let msgs_str = match unsafe { cstr(msgs_json) } {
659        Some(s) => s,
660        None => return session_err("null msgs_json"),
661    };
662    let msgs: Vec<SessionMessage> = match serde_json::from_str(msgs_str) {
663        Ok(v) => v,
664        Err(e) => return session_err(&format!("bad msgs_json: {e}")),
665    };
666    let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
667    let mut sm = SessionManager::new(std::path::Path::new(&pp));
668    match sm.append_session_messages(&sid, msgs) {
669        Ok(()) => to_c_string(json!({"success": true}).to_string()),
670        Err(e) => session_err(&e.to_string()),
671    }
672}
673
674/// Free a string previously returned by this library.
675#[no_mangle]
676pub extern "C" fn aacode_free_string(ptr: *mut c_char) {
677    // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
678    if ptr.is_null() {
679        return;
680    }
681    unsafe {
682        let _ = CString::from_raw(ptr);
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    extern "C" fn capture_cb(line: *const c_char, userdata: *mut c_void) {
691        if line.is_null() {
692            return;
693        }
694        let l = unsafe { CStr::from_ptr(line).to_str().unwrap_or("").to_string() };
695        let sink = userdata as *mut Mutex<Vec<String>>;
696        if !sink.is_null() {
697            unsafe {
698                if let Ok(mut v) = (*sink).lock() {
699                    v.push(l);
700                }
701            }
702        }
703    }
704
705    fn run_task(json: &str) -> (String, Vec<String>) {
706        let c = CString::new(json).unwrap();
707        let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
708        let ud = &lines as *const _ as *mut c_void;
709        let h = aacode_task_start(c.as_ptr(), Some(capture_cb), ud);
710        let result = unsafe {
711            let p = aacode_task_wait(h);
712            let s = CStr::from_ptr(p).to_str().unwrap().to_string();
713            aacode_free_string(p);
714            aacode_task_free(h);
715            s
716        };
717        let captured = lines.lock().unwrap().clone();
718        (result, captured)
719    }
720
721    #[test]
722    fn run_task_bad_json() {
723        let (out, events) = run_task("not json");
724        assert!(out.contains("bad task_json"), "got: {out}");
725        assert!(
726            events.iter().any(|l| l.contains(r#""type":"error""#) && l.contains("bad task_json")),
727            "error event must be emitted: {events:?}"
728        );
729    }
730
731    #[test]
732    fn run_task_missing_task() {
733        let (out, events) = run_task(r#"{"project_path":"/tmp"}"#);
734        assert!(out.contains("error"), "got: {out}");
735        assert!(events.iter().any(|l| l.contains(r#""type":"error""#)));
736    }
737
738    #[test]
739    fn callback_receives_userdata_on_early_error() {
740        use std::sync::atomic::AtomicUsize;
741        static RECEIVED_UD: AtomicUsize = AtomicUsize::new(0);
742        extern "C" fn ud_cb(_line: *const c_char, ud: *mut c_void) {
743            RECEIVED_UD.store(ud as usize, Ordering::SeqCst);
744        }
745        let c = CString::new(r#"{"project_path":"/tmp"}"#).unwrap();
746        let marker = 0xABCD1234usize as *mut c_void;
747        let h = aacode_task_start(c.as_ptr(), Some(ud_cb), marker);
748        let p = aacode_task_wait(h);
749        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
750        aacode_free_string(p);
751        aacode_task_free(h);
752        assert!(s.contains("missing task"), "got: {s}");
753        // The host's opaque userdata must be passed back verbatim to the callback.
754        assert_eq!(RECEIVED_UD.load(Ordering::SeqCst), 0xABCD1234usize);
755    }
756
757    #[test]
758    fn start_with_null_callback_still_returns_terminal_result() {
759        let c = CString::new("not json").unwrap();
760        let h = aacode_task_start(c.as_ptr(), None, std::ptr::null_mut());
761        let p = aacode_task_wait(h);
762        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
763        aacode_free_string(p);
764        aacode_task_free(h);
765        assert!(s.contains("bad task_json"), "got: {s}");
766    }
767
768    #[test]
769    fn null_task_json() {
770        let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
771        let ud = &lines as *const _ as *mut c_void;
772        let h = aacode_task_start(std::ptr::null(), Some(capture_cb), ud);
773        let result = unsafe {
774            let p = aacode_task_wait(h);
775            let s = CStr::from_ptr(p).to_str().unwrap().to_string();
776            aacode_free_string(p);
777            aacode_task_free(h);
778            s
779        };
780        assert!(result.contains("null task_json"));
781    }
782
783    #[test]
784    fn wait_null_handle_is_safe() {
785        let p = aacode_task_wait(std::ptr::null_mut());
786        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
787        aacode_free_string(p);
788        assert!(s.contains("error"));
789    }
790
791    #[test]
792    fn cancel_null_handle_is_safe() {
793        aacode_task_cancel(std::ptr::null_mut());
794    }
795
796    #[test]
797    fn free_null_is_safe() {
798        aacode_task_free(std::ptr::null_mut());
799        aacode_free_string(std::ptr::null_mut());
800    }
801
802    #[test]
803    fn session_guard_rejects_duplicate() {
804        let g1 = SessionGuard::try_acquire("p::s1".to_string());
805        assert!(g1.is_some());
806        let g2 = SessionGuard::try_acquire("p::s1".to_string());
807        assert!(g2.is_none(), "duplicate session must be rejected");
808        drop(g1);
809        let g3 = SessionGuard::try_acquire("p::s1".to_string());
810        assert!(g3.is_some(), "released session must be reacquirable");
811    }
812
813    #[test]
814    fn concurrent_tasks_same_session_rejected() {
815        let _hold = SessionGuard::try_acquire("/tmp/proj::sess_dup".to_string()).unwrap();
816        let (out, _events) = run_task(
817            r#"{"task":"do x","project_path":"/tmp/proj","session_id":"sess_dup","model":{"name":"m","api_key":"k"}}"#,
818        );
819        assert!(out.contains("already has a task running"), "got: {out}");
820    }
821
822    #[test]
823    fn list_sessions_ffi() {
824        let d = std::env::temp_dir().join(format!("ffi_ls_{}", uuid::Uuid::new_v4().simple()));
825        std::fs::create_dir_all(&d).unwrap();
826        {
827            let mut sm = SessionManager::new(&d);
828            sm.create_session("task", None).unwrap();
829        }
830        let c = CString::new(d.to_string_lossy().to_string()).unwrap();
831        let ptr = aacode_list_sessions(c.as_ptr());
832        let s = unsafe { CStr::from_ptr(ptr).to_str().unwrap().to_string() };
833        aacode_free_string(ptr);
834        assert!(s.contains("\"success\":true"));
835    }
836
837    // ── resolve_shell_root (skills user_dir root widening) ─────────
838
839    fn mkdirs(p: &std::path::Path) -> String {
840        std::fs::create_dir_all(p).unwrap();
841        p.to_string_lossy().to_string()
842    }
843
844    fn tmp_root(tag: &str) -> std::path::PathBuf {
845        std::env::temp_dir().join(format!("ffi_root_{tag}_{}", uuid::Uuid::new_v4().simple()))
846    }
847
848    #[test]
849    fn no_skills_config_keeps_project_jail() {
850        let base = tmp_root("nocfg");
851        let sb = mkdirs(&base.join("sb"));
852        let pp = mkdirs(&base.join("sb").join("proj"));
853        let (root, rel) = resolve_shell_root(&sb, &pp, None);
854        assert_eq!(root, pp);
855        assert!(rel.is_none());
856    }
857
858    #[test]
859    fn project_inside_sandbox_widens_and_positions_cwd() {
860        let base = tmp_root("inside");
861        let sb = mkdirs(&base.join("sb"));
862        let sk = mkdirs(&base.join("sb").join("skills"));
863        let pp = mkdirs(&base.join("sb").join("a").join("b"));
864        let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
865        assert_eq!(root, sb);
866        let rel = rel.expect("must reposition cwd");
867        assert_eq!(
868            std::path::PathBuf::from(rel),
869            std::path::PathBuf::from("a").join("b")
870        );
871    }
872
873    #[test]
874    fn project_equal_to_sandbox_user_dir_inside_no_widening() {
875        let base = tmp_root("equal_in");
876        let sb = mkdirs(&base.join("sb"));
877        let sk = mkdirs(&base.join("sb").join("skills"));
878        let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
879        assert_eq!(root, sb);
880        assert!(rel.is_none());
881    }
882
883    #[test]
884    fn project_equal_to_sandbox_user_dir_outside_widens() {
885        let base = tmp_root("equal_out");
886        let sb = mkdirs(&base.join("sb"));
887        let sk = mkdirs(&base.join("skills"));
888        let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
889        let base_c = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
890        assert_eq!(root, base_c.to_string_lossy());
891        assert_eq!(rel.as_deref(), Some("sb"));
892    }
893
894    #[test]
895    fn sibling_prefix_dir_is_not_inside_sandbox() {
896        let base = tmp_root("sibling");
897        let sb = mkdirs(&base.join("sb"));
898        let pp = mkdirs(&base.join("sb2").join("proj"));
899        let sk = mkdirs(&base.join("sb2").join("proj").join("skills"));
900        let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
901        assert_eq!(root, pp, "sibling project must keep its own jail");
902        assert!(rel.is_none());
903    }
904
905    #[test]
906    fn project_outside_sandbox_keeps_own_jail() {
907        let base = tmp_root("outside");
908        let sb = mkdirs(&base.join("sb"));
909        let pp = mkdirs(&base.join("elsewhere").join("proj"));
910        let sk = mkdirs(&base.join("elsewhere").join("proj").join("skills"));
911        let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
912        assert_eq!(root, pp);
913        assert!(rel.is_none());
914    }
915
916    #[cfg(unix)]
917    #[test]
918    fn symlinked_project_path_still_recognized() {
919        let base = tmp_root("symlink");
920        let real_sb = mkdirs(&base.join("real_sb"));
921        mkdirs(&base.join("real_sb").join("proj"));
922        let sk = mkdirs(&base.join("real_sb").join("skills"));
923        let link_sb = base.join("link_sb");
924        std::os::unix::fs::symlink(&real_sb, &link_sb).unwrap();
925        let pp_via_link = link_sb.join("proj").to_string_lossy().to_string();
926        let (root, rel) = resolve_shell_root(&real_sb, &pp_via_link, Some(&sk));
927        assert_eq!(root, real_sb, "symlinked project must widen to sandbox");
928        assert_eq!(rel.as_deref(), Some("proj"));
929    }
930
931    // ── SESSION_FFI store functions ──────────────────────────────────
932
933    fn call_str(f: impl FnOnce() -> *mut c_char) -> String {
934        let p = f();
935        let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
936        aacode_free_string(p);
937        s
938    }
939
940    #[test]
941    fn session_version_is_one() {
942        assert_eq!(aacode_session_version(), SCHEMA_VERSION);
943        assert_eq!(aacode_session_version(), 1);
944    }
945
946    #[test]
947    fn session_lifecycle_roundtrip() {
948        let proj = mkdirs(&tmp_root("sess_life"));
949        let pp = CString::new(proj).unwrap();
950        let sid = CString::new("sess_a").unwrap();
951
952        // ensure (create)
953        let title = CString::new("My Session").unwrap();
954        let r = call_str(|| aacode_session_ensure(pp.as_ptr(), sid.as_ptr(), title.as_ptr()));
955        assert!(r.contains("\"success\":true"), "ensure: {r}");
956
957        // append two messages
958        let msgs = r#"[{"role":"user","content":"hello","timestamp":"1700000001"},{"role":"assistant","content":"hi","timestamp":"1700000002"}]"#;
959        let mj = CString::new(msgs).unwrap();
960        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
961        assert!(r.contains("\"success\":true"), "append: {r}");
962
963        // rename
964        let new_title = CString::new("Renamed").unwrap();
965        let r = call_str(|| aacode_session_rename(pp.as_ptr(), sid.as_ptr(), new_title.as_ptr()));
966        assert!(r.contains("\"success\":true"), "rename: {r}");
967
968        // list
969        let r = call_str(|| aacode_session_list(pp.as_ptr()));
970        assert!(r.contains("\"success\":true"), "list: {r}");
971        assert!(r.contains("sess_a"));
972        assert!(r.contains("Renamed"));
973
974        // paginated messages
975        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
976        assert!(r.contains("\"success\":true"), "messages: {r}");
977        assert!(r.contains("\"total\":2"));
978        assert!(r.contains("hello"));
979
980        // touch
981        let r = call_str(|| aacode_session_touch(pp.as_ptr(), sid.as_ptr()));
982        assert!(r.contains("\"success\":true"), "touch: {r}");
983
984        // delete (idempotent)
985        let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
986        assert!(r.contains("\"success\":true"), "delete: {r}");
987        let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
988        assert!(r.contains("\"success\":true"), "delete again (idempotent): {r}");
989    }
990
991    #[test]
992    fn session_append_bad_json_errors() {
993        let proj = mkdirs(&tmp_root("sess_bad"));
994        let pp = CString::new(proj).unwrap();
995        let sid = CString::new("s1").unwrap();
996        let bad = CString::new("not json").unwrap();
997        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), bad.as_ptr()));
998        assert!(r.contains("\"success\":false"), "bad msgs_json must error: {r}");
999        assert!(r.contains("bad msgs_json"));
1000    }
1001
1002    #[test]
1003    fn session_messages_pagination() {
1004        let proj = mkdirs(&tmp_root("sess_page"));
1005        let pp = CString::new(proj.clone()).unwrap();
1006        let sid = CString::new("s1").unwrap();
1007        {
1008            let mut sm = SessionManager::new(std::path::Path::new(&proj));
1009            sm.ensure_session("s1", "").unwrap();
1010            for i in 0..5 {
1011                sm.append_session_messages(
1012                    "s1",
1013                    vec![SessionMessage {
1014                        role: "user".to_string(),
1015                        content: format!("m{i}"),
1016                        timestamp: format!("17000000{i:02}"),
1017                        tokens: 1,
1018                        tool_calls: None,
1019                        tool_call_id: None,
1020                        reasoning_content: None,
1021                        segments: None,
1022                    }],
1023                )
1024                .unwrap();
1025            }
1026        }
1027        // offset=0 limit=2 → newest two (m3, m4)
1028        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 2));
1029        assert!(r.contains("\"total\":5"), "total: {r}");
1030        assert!(r.contains("m4") && r.contains("m3") && !r.contains("m0"), "page0: {r}");
1031        // offset=2 limit=2 → m1, m2
1032        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 2, 2));
1033        assert!(r.contains("m2") && r.contains("m1") && !r.contains("m3"), "page1: {r}");
1034    }
1035
1036    #[test]
1037    fn session_messages_null_args_error() {
1038        let r = call_str(|| aacode_session_messages(std::ptr::null(), std::ptr::null(), 0, 50));
1039        assert!(r.contains("\"success\":false"), "null args: {r}");
1040    }
1041
1042    #[test]
1043    fn session_ffi_rejects_path_traversal_sid() {
1044        let proj = mkdirs(&tmp_root("sess_trav"));
1045        let pp = CString::new(proj).unwrap();
1046        let evil = CString::new("../escape").unwrap();
1047        let r = call_str(|| aacode_session_ensure(pp.as_ptr(), evil.as_ptr(), std::ptr::null()));
1048        assert!(r.contains("\"success\":false"), "path traversal must be rejected: {r}");
1049        assert!(r.contains("invalid session_id"));
1050        let r = call_str(|| aacode_session_messages(pp.as_ptr(), evil.as_ptr(), 0, 50));
1051        assert!(r.contains("\"success\":false"));
1052        let r = call_str(|| aacode_session_delete(pp.as_ptr(), evil.as_ptr()));
1053        assert!(r.contains("\"success\":false"));
1054    }
1055
1056    #[test]
1057    fn session_messages_limit_zero_and_beyond_total() {
1058        let proj = mkdirs(&tmp_root("sess_l0"));
1059        let pp = CString::new(proj.clone()).unwrap();
1060        let sid = CString::new("s1").unwrap();
1061        {
1062            let mut sm = SessionManager::new(std::path::Path::new(&proj));
1063            sm.ensure_session("s1", "").unwrap();
1064            sm.append_session_messages(
1065                "s1",
1066                vec![SessionMessage {
1067                    role: "user".to_string(),
1068                    content: "hi".to_string(),
1069                    timestamp: "1".to_string(),
1070                    tokens: 1,
1071                    tool_calls: None,
1072                    tool_call_id: None,
1073                    reasoning_content: None,
1074                    segments: None,
1075                }],
1076            )
1077            .unwrap();
1078        }
1079        // limit=0 → no messages, total still reported.
1080        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 0));
1081        assert!(r.contains("\"total\":1"), "limit=0 total: {r}");
1082        assert!(!r.contains("\"hi\""), "limit=0 must return no messages: {r}");
1083        // offset beyond total → empty.
1084        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 5, 10));
1085        assert!(r.contains("\"total\":1"));
1086        assert!(!r.contains("\"hi\""), "offset beyond total must be empty: {r}");
1087    }
1088
1089    #[test]
1090    fn session_unicode_content_roundtrip() {
1091        let proj = mkdirs(&tmp_root("sess_uni"));
1092        let pp = CString::new(proj).unwrap();
1093        let sid = CString::new("s1").unwrap();
1094        let msgs = r#"[{"role":"user","content":"你好,世界 🌍","timestamp":"1700000001"}]"#;
1095        let mj = CString::new(msgs).unwrap();
1096        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1097        assert!(r.contains("\"success\":true"), "append unicode: {r}");
1098        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1099        assert!(r.contains("你好,世界"), "unicode content preserved: {r}");
1100    }
1101
1102    #[test]
1103    fn session_append_and_read_segments() {
1104        let proj = mkdirs(&tmp_root("sess_segs"));
1105        let pp = CString::new(proj).unwrap();
1106        let sid = CString::new("s1").unwrap();
1107        let msgs = r#"[
1108            {"role":"assistant","content":"ok","timestamp":"1700000001",
1109             "segments":[
1110               {"type":"thinking","content":"think","created_at":"1700000001.001"},
1111               {"type":"action","name":"run_shell","content":"{}","created_at":"1700000001.002"}
1112             ]}
1113        ]"#;
1114        let mj = CString::new(msgs).unwrap();
1115        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1116        assert!(r.contains("\"success\":true"), "append segments: {r}");
1117
1118        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1119        assert!(r.contains("\"type\":\"thinking\""), "thinking segment round-trips: {r}");
1120        assert!(r.contains("\"name\":\"run_shell\""), "action name round-trips: {r}");
1121        assert!(r.contains("\"created_at\":\"1700000001.001\""), "created_at round-trips: {r}");
1122    }
1123
1124    #[test]
1125    fn session_append_without_segments_still_works() {
1126        let proj = mkdirs(&tmp_root("sess_noseg"));
1127        let pp = CString::new(proj).unwrap();
1128        let sid = CString::new("s1").unwrap();
1129        let msgs = r#"[{"role":"user","content":"hi","timestamp":"1700000001"}]"#;
1130        let mj = CString::new(msgs).unwrap();
1131        let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1132        assert!(r.contains("\"success\":true"), "append without segments: {r}");
1133        let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1134        assert!(r.contains("\"hi\""), "message without segments still read: {r}");
1135        // Optional field rendered as null in the wire (consistent with tool_calls).
1136        assert!(r.contains("\"segments\":null"), "absent segments serialize as null: {r}");
1137    }
1138
1139    #[test]
1140    fn legacy_get_session_messages_carries_segments() {
1141        let proj = mkdirs(&tmp_root("sess_legacy_seg"));
1142        let pp = CString::new(proj.clone()).unwrap();
1143        let sid = CString::new("s1").unwrap();
1144        {
1145            let mut sm = SessionManager::new(std::path::Path::new(&proj));
1146            sm.ensure_session("s1", "").unwrap();
1147            sm.append_session_messages(
1148                "s1",
1149                vec![SessionMessage {
1150                    role: "assistant".to_string(),
1151                    content: "ok".to_string(),
1152                    timestamp: "1700000001".to_string(),
1153                    tokens: 1,
1154                    tool_calls: None,
1155                    tool_call_id: None,
1156                    reasoning_content: None,
1157                    segments: Some(vec![crate::session::MessageSegment {
1158                        kind: "thought".into(),
1159                        content: "ok".into(),
1160                        name: None,
1161                        created_at: Some("1700000001.001".into()),
1162                    }]),
1163                }],
1164            )
1165            .unwrap();
1166        }
1167        let r = call_str(|| aacode_get_session_messages(pp.as_ptr(), sid.as_ptr()));
1168        assert!(r.contains("\"success\":true"), "legacy read: {r}");
1169        assert!(r.contains("\"type\":\"thought\""), "legacy carries segments: {r}");
1170        assert!(r.contains("\"created_at\":\"1700000001.001\""), "legacy segment created_at: {r}");
1171    }
1172
1173    #[test]
1174    fn session_list_empty_project() {
1175        let proj = mkdirs(&tmp_root("sess_empty"));
1176        let pp = CString::new(proj).unwrap();
1177        let r = call_str(|| aacode_session_list(pp.as_ptr()));
1178        assert!(r.contains("\"success\":true"));
1179        assert!(r.contains("\"sessions\":[]"), "empty project: {r}");
1180    }
1181}