#![allow(clippy::not_unsafe_ptr_arg_deref)]
use crate::config::AgentConfig;
use crate::runtime::{AgentRuntime, TOKIO_RT};
use crate::session::{valid_session_id, SessionManager, SessionMessage, SCHEMA_VERSION};
use crate::stream::CallbackSink;
use serde_json::json;
use std::ffi::{c_char, c_void, CStr, CString};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex, OnceLock};
pub type StreamCallback = extern "C" fn(*const c_char, *mut c_void);
pub struct AacodeTask {
cancel: Arc<AtomicBool>,
result_rx: mpsc::Receiver<String>,
tx: mpsc::Sender<String>,
join: Mutex<Option<tokio::task::JoinHandle<()>>>,
session_key: Option<String>,
fs_cancel: Option<Arc<AtomicBool>>,
}
impl AacodeTask {
fn new(
cancel: Arc<AtomicBool>,
result_rx: mpsc::Receiver<String>,
tx: mpsc::Sender<String>,
join: Option<tokio::task::JoinHandle<()>>,
session_key: Option<String>,
fs_cancel: Option<Arc<AtomicBool>>,
) -> Self {
AacodeTask {
cancel,
result_rx,
tx,
join: Mutex::new(join),
session_key,
fs_cancel,
}
}
}
struct SendUserData(*mut c_void);
unsafe impl Send for SendUserData {}
unsafe impl Sync for SendUserData {}
impl SendUserData {
#[inline]
fn ptr(&self) -> *mut c_void {
self.0
}
}
static RUNNING_SESSIONS: OnceLock<Mutex<std::collections::HashMap<String, Arc<()>>>> =
OnceLock::new();
fn running_sessions() -> &'static Mutex<std::collections::HashMap<String, Arc<()>>> {
RUNNING_SESSIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
fn release_session(key: &str) {
running_sessions()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(key);
}
struct SessionGuard {
key: String,
token: Arc<()>,
}
impl SessionGuard {
fn try_acquire(key: String) -> Option<Self> {
let mut map = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
if map.contains_key(&key) {
return None;
}
let token = Arc::new(());
map.insert(key.clone(), token.clone());
Some(SessionGuard { key, token })
}
}
impl Drop for SessionGuard {
fn drop(&mut self) {
let mut map = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
if let Some(cur) = map.get(&self.key) {
if Arc::ptr_eq(cur, &self.token) {
map.remove(&self.key);
}
}
}
}
unsafe fn cstr<'a>(ptr: *const c_char) -> Option<&'a str> {
if ptr.is_null() {
return None;
}
CStr::from_ptr(ptr).to_str().ok()
}
fn to_c_string(s: String) -> *mut c_char {
CString::new(s)
.unwrap_or_else(|_| CString::new("").unwrap())
.into_raw()
}
fn guard_cchar(f: impl FnOnce() -> *mut c_char) -> *mut c_char {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(ptr) => ptr,
Err(_) => to_c_string(
serde_json::json!({"success": false, "error": "internal panic"}).to_string(),
),
}
}
fn resolve_shell_root(
sandbox: &str,
project: &str,
user_dir: Option<&str>,
) -> (String, Option<String>) {
let Some(user_dir) = user_dir else {
return (project.to_string(), None);
};
let canon = |s: &str| std::fs::canonicalize(s).unwrap_or_else(|_| std::path::PathBuf::from(s));
let sandbox_c = canon(sandbox);
let project_c = canon(project);
let user_dir_c = canon(user_dir);
match project_c.strip_prefix(&sandbox_c) {
Ok(rel) if !rel.as_os_str().is_empty() => {
return (sandbox.to_string(), Some(rel.to_string_lossy().to_string()));
}
_ => {}
}
if user_dir_c.strip_prefix(&project_c).is_ok() {
return (project.to_string(), None);
}
for ancestor in project_c.ancestors().skip(1) {
if ancestor.parent().is_none() {
break;
}
if user_dir_c.strip_prefix(ancestor).is_ok() {
if let Ok(rel) = project_c.strip_prefix(ancestor) {
if !rel.as_os_str().is_empty() {
return (
ancestor.to_string_lossy().to_string(),
Some(rel.to_string_lossy().to_string()),
);
}
}
}
}
(project.to_string(), None)
}
fn build_runtime(
config: &AgentConfig,
project_path: &str,
) -> Result<(AgentRuntime, Option<Arc<AtomicBool>>), String> {
if let Some(sdk) = fastshell::sdk::try_get_sdk_instance() {
let sandbox = {
let guard = sdk.lock().unwrap_or_else(|e| e.into_inner());
guard.vfs_root()
};
let pp = if project_path == "." {
sandbox.clone()
} else {
project_path.to_string()
};
let (shell_root, cd_rel) =
resolve_shell_root(&sandbox, &pp, config.skills.user_dir.as_deref());
let mut cfg = config.clone();
if let Some(ref user_dir) = cfg.skills.user_dir {
if let Ok(rel) = std::path::Path::new(user_dir).strip_prefix(&shell_root) {
if !rel.as_os_str().is_empty() {
cfg.skills.vfs_skills_dir = Some(format!("/{}", rel.to_string_lossy()));
}
}
}
let fs_arc: Arc<Mutex<fastshell::Fastshell>> =
Arc::new(Mutex::new(fastshell::Fastshell::new()));
{
let mut ours = fs_arc.lock().unwrap();
let mut fscfg = fastshell::Config::default();
fscfg.sandbox_path = shell_root.clone();
fscfg.python_enabled = true;
fscfg.allow_subprocess = false; fscfg.network_ask_permission = false;
if let Err(e) = ours.init(fscfg) {
return Err(e.to_string());
}
if let Some(rel) = cd_rel {
let r = ours.execute(&format!("cd '{}'", rel.replace('\'', "'\\''")));
if r.exit_code != 0 {
return Err(format!("cd to project failed: {}", r.stderr));
}
}
}
let fs_cancel = fs_arc
.lock()
.unwrap_or_else(|e| e.into_inner())
.cancel_handle();
Ok((
AgentRuntime::with_fastshell(cfg, std::path::PathBuf::from(pp), fs_arc),
Some(fs_cancel),
))
} else {
AgentRuntime::init(config.clone(), std::path::PathBuf::from(project_path))
.map(|rt| (rt, None))
.map_err(|e| e.to_string())
}
}
#[no_mangle]
pub extern "C" fn aacode_task_start(
task_json: *const c_char,
cb: Option<StreamCallback>,
userdata: *mut c_void,
) -> *mut AacodeTask {
let (tx, rx) = mpsc::channel::<String>();
let cancel = Arc::new(AtomicBool::new(false));
let ud = SendUserData(userdata);
let emit = move |line: &str| {
if let Some(cb) = cb {
if let Ok(cs) = CString::new(line) {
cb(cs.as_ptr(), ud.ptr());
}
}
};
let input = match unsafe { cstr(task_json) } {
Some(s) => s.to_string(),
None => {
let msg = "null task_json";
emit(&json!({"type": "error", "message": msg}).to_string());
let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
return Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx.clone(),
None,
None,
None,
)));
}
};
let v: serde_json::Value = match serde_json::from_str(&input) {
Ok(v) => v,
Err(e) => {
let msg = format!("bad task_json: {e}");
emit(&json!({"type": "error", "message": msg}).to_string());
let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
return Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx.clone(),
None,
None,
None,
)));
}
};
let task = v
.get("task")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if task.is_empty() {
let msg = "missing task";
emit(&json!({"type": "error", "message": msg}).to_string());
let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
return Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx.clone(),
None,
None,
None,
)));
}
let project_path = v
.get("project_path")
.and_then(|x| x.as_str())
.unwrap_or(".")
.to_string();
let session_id = v
.get("session_id")
.and_then(|x| x.as_str())
.map(|s| s.to_string());
let session_key: Option<String> = session_id
.as_ref()
.map(|sid| format!("{project_path}::{sid}"));
let session_guard = match &session_key {
Some(key) => match SessionGuard::try_acquire(key.clone()) {
Some(g) => Some(g),
None => {
let sid = session_id.as_deref().unwrap_or("");
let msg = format!("session '{sid}' already has a task running");
emit(&json!({"type": "error", "message": msg}).to_string());
let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
return Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx.clone(),
None,
None,
None,
)));
}
},
None => None,
};
let mut config: AgentConfig = serde_json::from_value(v).unwrap_or_default();
config.apply_env();
let (rt, fs_cancel) = match build_runtime(&config, &project_path) {
Ok(x) => x,
Err(e) => {
emit(&json!({"type": "error", "message": e}).to_string());
let _ = tx.send(json!({"status": "error", "error": e}).to_string());
return Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx.clone(),
None,
None,
None,
)));
}
};
let cancel2 = cancel.clone();
let tx_for_handle = tx.clone();
let join = TOKIO_RT.spawn(async move {
let _guard = session_guard;
use futures::FutureExt;
let result = std::panic::AssertUnwindSafe(rt.run_task(
&task,
session_id.as_deref(),
&CallbackSink::new(Box::new(emit)),
&cancel2,
))
.catch_unwind()
.await;
let json = match result {
Ok(Ok(res)) => res.to_result_json().to_string(),
Ok(Err(e)) => json!({"status": "error", "error": e.to_string()}).to_string(),
Err(_) => {
json!({"status": "error", "error": "agent panicked (internal error)"}).to_string()
}
};
let _ = tx.send(json);
});
Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx_for_handle,
Some(join),
session_key,
fs_cancel,
)))
}
#[no_mangle]
pub extern "C" fn aacode_task_wait(handle: *mut AacodeTask) -> *mut c_char {
if handle.is_null() {
return to_c_string(json!({"status": "error", "error": "null handle"}).to_string());
}
let h = unsafe { &*handle };
let result = h.result_rx.recv().unwrap_or_else(|_| {
json!({"status": "error", "error": "task was dropped before completion"}).to_string()
});
to_c_string(result)
}
#[no_mangle]
pub extern "C" fn aacode_task_cancel(handle: *mut AacodeTask) {
if handle.is_null() {
return;
}
let h = unsafe { &*handle };
h.cancel.store(true, Ordering::SeqCst);
if let Some(c) = &h.fs_cancel {
c.store(true, Ordering::SeqCst);
}
if let Some(join) = h.join.lock().unwrap_or_else(|e| e.into_inner()).take() {
join.abort();
}
if let Some(k) = &h.session_key {
release_session(k);
}
let _ = h.tx.send(json!({"status": "cancelled"}).to_string());
}
#[no_mangle]
pub extern "C" fn aacode_task_free(handle: *mut AacodeTask) {
if handle.is_null() {
return;
}
{
let h = unsafe { &*handle };
if let Some(join) = h.join.lock().unwrap_or_else(|e| e.into_inner()).take() {
join.abort();
if let Some(c) = &h.fs_cancel {
c.store(true, Ordering::SeqCst);
}
}
if let Some(k) = &h.session_key {
release_session(k);
}
}
unsafe {
drop(Box::from_raw(handle));
}
}
#[no_mangle]
pub extern "C" fn aacode_validate_api_key(config_json: *const c_char) -> *mut c_char {
guard_cchar(|| {
let input = match unsafe { cstr(config_json) } {
Some(s) => s.to_string(),
None => {
return to_c_string(json!({"valid": false, "error": "null config"}).to_string())
}
};
let mut config: AgentConfig = serde_json::from_str(&input).unwrap_or_default();
config.apply_env();
let client = crate::llm::build_client(&config.model);
let handle = TOKIO_RT.handle().clone();
let out = std::thread::scope(|s| {
s.spawn(
move || match handle.block_on(async { client.validate().await }) {
Ok(()) => json!({"valid": true}),
Err(e) => json!({"valid": false, "error": e.to_string()}),
},
)
.join()
.unwrap_or_else(|_| json!({"valid": false, "error": "validation thread panicked"}))
});
to_c_string(out.to_string())
})
}
#[no_mangle]
pub extern "C" fn aacode_list_sessions(project_path: *const c_char) -> *mut c_char {
guard_cchar(|| {
let pp = match unsafe { cstr(project_path) } {
Some(s) => s.to_string(),
None => {
return to_c_string(json!({"success": false, "error": "null path"}).to_string())
}
};
let sm = SessionManager::new(std::path::Path::new(&pp));
let sessions: Vec<serde_json::Value> = sm
.list_sessions()
.into_iter()
.map(|s| {
json!({
"session_id": s.session_id,
"title": s.title,
"created_at": s.created_at,
"last_activity": s.last_activity,
"total_messages": s.total_messages,
"status": s.status,
})
})
.collect();
to_c_string(json!({"success": true, "sessions": sessions}).to_string())
})
}
#[no_mangle]
pub extern "C" fn aacode_get_session_messages(
project_path: *const c_char,
session_id: *const c_char,
) -> *mut c_char {
guard_cchar(|| {
let pp = unsafe { cstr(project_path) }.unwrap_or("").to_string();
let sid = unsafe { cstr(session_id) }.unwrap_or("").to_string();
let sm = SessionManager::new(std::path::Path::new(&pp));
let msgs = sm.read_session_messages(&sid);
let arr: Vec<serde_json::Value> = msgs
.iter()
.map(|m| {
json!({
"role": m.role,
"content": m.content,
"timestamp": m.timestamp,
"tool_calls": m.tool_calls,
"tool_call_id": m.tool_call_id,
"reasoning_content": m.reasoning_content,
})
})
.collect();
to_c_string(json!({"success": true, "messages": arr}).to_string())
})
}
static SESSION_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn message_to_json(m: &SessionMessage) -> serde_json::Value {
json!({
"role": m.role,
"content": m.content,
"timestamp": m.timestamp,
"tokens": m.tokens,
"tool_calls": m.tool_calls,
"tool_call_id": m.tool_call_id,
"reasoning_content": m.reasoning_content,
})
}
fn session_err(msg: &str) -> *mut c_char {
to_c_string(json!({"success": false, "error": msg}).to_string())
}
fn session_id_args(
project_path: *const c_char,
session_id: *const c_char,
) -> Result<(String, String), String> {
let pp = unsafe { cstr(project_path) }
.ok_or_else(|| "null project_path".to_string())?
.to_string();
let sid = unsafe { cstr(session_id) }
.ok_or_else(|| "null session_id".to_string())?
.to_string();
if !valid_session_id(&sid) {
return Err("invalid session_id".to_string());
}
Ok((pp, sid))
}
#[no_mangle]
pub extern "C" fn aacode_session_version() -> u32 {
SCHEMA_VERSION
}
#[no_mangle]
pub extern "C" fn aacode_session_list(project_path: *const c_char) -> *mut c_char {
guard_cchar(|| {
let pp = match unsafe { cstr(project_path) } {
Some(s) => s.to_string(),
None => return session_err("null project_path"),
};
let sm = SessionManager::new(std::path::Path::new(&pp));
let sessions: Vec<serde_json::Value> = sm
.list_sessions()
.into_iter()
.map(|s| {
json!({
"session_id": s.session_id,
"title": s.title,
"created_at": s.created_at,
"last_activity": s.last_activity,
"total_messages": s.total_messages,
"total_tokens": s.total_tokens,
"status": s.status,
})
})
.collect();
to_c_string(json!({"success": true, "sessions": sessions}).to_string())
})
}
#[no_mangle]
pub extern "C" fn aacode_session_messages(
project_path: *const c_char,
session_id: *const c_char,
offset: u32,
limit: u32,
) -> *mut c_char {
guard_cchar(|| {
let (pp, sid) = match session_id_args(project_path, session_id) {
Ok(v) => v,
Err(e) => return session_err(&e),
};
let sm = SessionManager::new(std::path::Path::new(&pp));
let all = sm.read_session_messages(&sid);
let total = all.len();
let offset = offset as usize;
let limit = limit as usize;
let end = total.saturating_sub(offset);
let start = end.saturating_sub(limit);
let slice: Vec<serde_json::Value> = if start < end {
all[start..end].iter().map(message_to_json).collect()
} else {
Vec::new()
};
to_c_string(
json!({
"success": true,
"total": total,
"offset": offset,
"limit": limit,
"messages": slice,
})
.to_string(),
)
})
}
#[no_mangle]
pub extern "C" fn aacode_session_ensure(
project_path: *const c_char,
session_id: *const c_char,
title: *const c_char,
) -> *mut c_char {
guard_cchar(|| {
let (pp, sid) = match session_id_args(project_path, session_id) {
Ok(v) => v,
Err(e) => return session_err(&e),
};
let title = unsafe { cstr(title) }.unwrap_or("").to_string();
let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut sm = SessionManager::new(std::path::Path::new(&pp));
match sm.ensure_session(&sid, &title) {
Ok(()) => to_c_string(json!({"success": true}).to_string()),
Err(e) => session_err(&e.to_string()),
}
})
}
#[no_mangle]
pub extern "C" fn aacode_session_rename(
project_path: *const c_char,
session_id: *const c_char,
title: *const c_char,
) -> *mut c_char {
guard_cchar(|| {
let (pp, sid) = match session_id_args(project_path, session_id) {
Ok(v) => v,
Err(e) => return session_err(&e),
};
let title = unsafe { cstr(title) }.unwrap_or("").to_string();
let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut sm = SessionManager::new(std::path::Path::new(&pp));
match sm.rename_session(&sid, &title) {
Ok(()) => to_c_string(json!({"success": true}).to_string()),
Err(e) => session_err(&e.to_string()),
}
})
}
#[no_mangle]
pub extern "C" fn aacode_session_touch(
project_path: *const c_char,
session_id: *const c_char,
) -> *mut c_char {
guard_cchar(|| {
let (pp, sid) = match session_id_args(project_path, session_id) {
Ok(v) => v,
Err(e) => return session_err(&e),
};
let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut sm = SessionManager::new(std::path::Path::new(&pp));
match sm.touch_session(&sid) {
Ok(()) => to_c_string(json!({"success": true}).to_string()),
Err(e) => session_err(&e.to_string()),
}
})
}
#[no_mangle]
pub extern "C" fn aacode_session_delete(
project_path: *const c_char,
session_id: *const c_char,
) -> *mut c_char {
guard_cchar(|| {
let (pp, sid) = match session_id_args(project_path, session_id) {
Ok(v) => v,
Err(e) => return session_err(&e),
};
let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut sm = SessionManager::new(std::path::Path::new(&pp));
match sm.delete_session(&sid) {
Ok(_) => to_c_string(json!({"success": true}).to_string()),
Err(e) => session_err(&e.to_string()),
}
})
}
#[no_mangle]
pub extern "C" fn aacode_session_append(
project_path: *const c_char,
session_id: *const c_char,
msgs_json: *const c_char,
) -> *mut c_char {
guard_cchar(|| {
let (pp, sid) = match session_id_args(project_path, session_id) {
Ok(v) => v,
Err(e) => return session_err(&e),
};
let msgs_str = match unsafe { cstr(msgs_json) } {
Some(s) => s,
None => return session_err("null msgs_json"),
};
let msgs: Vec<SessionMessage> = match serde_json::from_str(msgs_str) {
Ok(v) => v,
Err(e) => return session_err(&format!("bad msgs_json: {e}")),
};
let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut sm = SessionManager::new(std::path::Path::new(&pp));
match sm.append_session_messages(&sid, msgs) {
Ok(()) => to_c_string(json!({"success": true}).to_string()),
Err(e) => session_err(&e.to_string()),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn aacode_browser_register_webview_ops(ops: *const c_void) -> i32 {
#[cfg(feature = "browser")]
{
if ops.is_null() {
return -1;
}
fastbrowser::sdk::ffi::fastbrowser_register_webview_ops(
ops as *const fastbrowser::sdk::ffi::FbWebViewOps,
)
}
#[cfg(not(feature = "browser"))]
{
let _ = ops;
-1
}
}
#[no_mangle]
pub unsafe extern "C" fn aacode_browser_register_surface_ops(ops: *const c_void) -> i32 {
#[cfg(feature = "browser")]
{
if ops.is_null() {
return -1;
}
fastbrowser::sdk::ffi::fastbrowser_register_surface_ops(
ops as *const fastbrowser::sdk::ffi::FbSurfaceOps,
)
}
#[cfg(not(feature = "browser"))]
{
let _ = ops;
-1
}
}
#[no_mangle]
pub extern "C" fn aacode_browser_available() -> i32 {
#[cfg(feature = "browser")]
{
if crate::tools::browser::is_available() {
1
} else {
0
}
}
#[cfg(not(feature = "browser"))]
{
0
}
}
#[no_mangle]
pub extern "C" fn aacode_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
let _ = CString::from_raw(ptr);
}
}
#[cfg(feature = "pty")]
static TERMINALS: OnceLock<Mutex<crate::terminal::TerminalManager>> = OnceLock::new();
#[cfg(feature = "pty")]
fn terminals() -> &'static Mutex<crate::terminal::TerminalManager> {
TERMINALS.get_or_init(|| {
Mutex::new(crate::terminal::TerminalManager::new(
crate::terminal::MAX_SESSIONS,
))
})
}
#[cfg(feature = "pty")]
#[no_mangle]
pub extern "C" fn aacode_terminal_open(
shell: *const c_char,
cwd: *const c_char,
cols: u16,
rows: u16,
) -> *mut c_char {
let shell = unsafe { cstr(shell) }.unwrap_or("/bin/sh");
let cwd = unsafe { cstr(cwd) }.unwrap_or(".");
let res = terminals()
.lock()
.unwrap()
.start(shell, std::path::Path::new(cwd), &[], cols, rows);
match res {
Ok(id) => to_c_string(json!({"ok": true, "id": id}).to_string()),
Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
}
}
#[cfg(feature = "pty")]
#[no_mangle]
pub extern "C" fn aacode_terminal_write(
id: *const c_char,
data: *const u8,
len: usize,
) -> *mut c_char {
let Some(id) = (unsafe { cstr(id) }) else {
return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
};
if data.is_null() {
return to_c_string(json!({"ok": false, "error": "null data"}).to_string());
}
let bytes = unsafe { std::slice::from_raw_parts(data, len) };
match terminals().lock().unwrap().write(id, bytes) {
Ok(()) => to_c_string(json!({"ok": true}).to_string()),
Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
}
}
#[cfg(feature = "pty")]
#[no_mangle]
pub extern "C" fn aacode_terminal_read(
id: *const c_char,
cursor: u64,
max_bytes: usize,
) -> *mut c_char {
use base64::Engine as _;
let Some(id) = (unsafe { cstr(id) }) else {
return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
};
match terminals().lock().unwrap().read(id, cursor, max_bytes) {
Ok(r) => {
let b64 = base64::engine::general_purpose::STANDARD.encode(&r.data);
to_c_string(
json!({
"ok": true,
"data_b64": b64,
"cursor": r.cursor,
"eof": r.eof,
"exit_code": r.exit_code,
"truncated": r.truncated,
})
.to_string(),
)
}
Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
}
}
#[cfg(feature = "pty")]
#[no_mangle]
pub extern "C" fn aacode_terminal_resize(id: *const c_char, cols: u16, rows: u16) -> *mut c_char {
let Some(id) = (unsafe { cstr(id) }) else {
return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
};
match terminals().lock().unwrap().resize(id, cols, rows) {
Ok(()) => to_c_string(json!({"ok": true}).to_string()),
Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
}
}
#[cfg(feature = "pty")]
#[no_mangle]
pub extern "C" fn aacode_terminal_close(id: *const c_char) -> *mut c_char {
let Some(id) = (unsafe { cstr(id) }) else {
return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
};
match terminals().lock().unwrap().close(id) {
Ok(()) => to_c_string(json!({"ok": true}).to_string()),
Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
}
}
#[cfg(feature = "pty")]
#[no_mangle]
pub extern "C" fn aacode_terminal_list() -> *mut c_char {
let list = terminals().lock().unwrap().list();
let sessions: Vec<serde_json::Value> = list
.into_iter()
.map(|s| {
json!({
"id": s.id,
"elapsed_ms": s.elapsed_ms,
"eof": s.eof,
"exit_code": s.exit_code,
"buffered": s.buffered,
})
})
.collect();
to_c_string(json!({"ok": true, "sessions": sessions}).to_string())
}
#[cfg(not(feature = "pty"))]
#[no_mangle]
pub extern "C" fn aacode_terminal_open(
_shell: *const c_char,
_cwd: *const c_char,
_cols: u16,
_rows: u16,
) -> *mut c_char {
to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
}
#[cfg(not(feature = "pty"))]
#[no_mangle]
pub extern "C" fn aacode_terminal_write(
_id: *const c_char,
_data: *const u8,
_len: usize,
) -> *mut c_char {
to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
}
#[cfg(not(feature = "pty"))]
#[no_mangle]
pub extern "C" fn aacode_terminal_read(
_id: *const c_char,
_cursor: u64,
_max_bytes: usize,
) -> *mut c_char {
to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
}
#[cfg(not(feature = "pty"))]
#[no_mangle]
pub extern "C" fn aacode_terminal_resize(
_id: *const c_char,
_cols: u16,
_rows: u16,
) -> *mut c_char {
to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
}
#[cfg(not(feature = "pty"))]
#[no_mangle]
pub extern "C" fn aacode_terminal_close(_id: *const c_char) -> *mut c_char {
to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
}
#[cfg(not(feature = "pty"))]
#[no_mangle]
pub extern "C" fn aacode_terminal_list() -> *mut c_char {
to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
extern "C" fn capture_cb(line: *const c_char, userdata: *mut c_void) {
if line.is_null() {
return;
}
let l = unsafe { CStr::from_ptr(line).to_str().unwrap_or("").to_string() };
let sink = userdata as *mut Mutex<Vec<String>>;
if !sink.is_null() {
unsafe {
if let Ok(mut v) = (*sink).lock() {
v.push(l);
}
}
}
}
fn run_task(json: &str) -> (String, Vec<String>) {
let c = CString::new(json).unwrap();
let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
let ud = &lines as *const _ as *mut c_void;
let h = aacode_task_start(c.as_ptr(), Some(capture_cb), ud);
let result = unsafe {
let p = aacode_task_wait(h);
let s = CStr::from_ptr(p).to_str().unwrap().to_string();
aacode_free_string(p);
aacode_task_free(h);
s
};
let captured = lines.lock().unwrap().clone();
(result, captured)
}
#[test]
fn run_task_bad_json() {
let (out, events) = run_task("not json");
assert!(out.contains("bad task_json"), "got: {out}");
assert!(
events
.iter()
.any(|l| l.contains(r#""type":"error""#) && l.contains("bad task_json")),
"error event must be emitted: {events:?}"
);
}
#[test]
fn run_task_missing_task() {
let (out, events) = run_task(r#"{"project_path":"/tmp"}"#);
assert!(out.contains("error"), "got: {out}");
assert!(events.iter().any(|l| l.contains(r#""type":"error""#)));
}
#[test]
fn callback_receives_userdata_on_early_error() {
use std::sync::atomic::AtomicUsize;
static RECEIVED_UD: AtomicUsize = AtomicUsize::new(0);
extern "C" fn ud_cb(_line: *const c_char, ud: *mut c_void) {
RECEIVED_UD.store(ud as usize, Ordering::SeqCst);
}
let c = CString::new(r#"{"project_path":"/tmp"}"#).unwrap();
let marker = 0xABCD1234usize as *mut c_void;
let h = aacode_task_start(c.as_ptr(), Some(ud_cb), marker);
let p = aacode_task_wait(h);
let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
aacode_free_string(p);
aacode_task_free(h);
assert!(s.contains("missing task"), "got: {s}");
assert_eq!(RECEIVED_UD.load(Ordering::SeqCst), 0xABCD1234usize);
}
#[test]
fn start_with_null_callback_still_returns_terminal_result() {
let c = CString::new("not json").unwrap();
let h = aacode_task_start(c.as_ptr(), None, std::ptr::null_mut());
let p = aacode_task_wait(h);
let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
aacode_free_string(p);
aacode_task_free(h);
assert!(s.contains("bad task_json"), "got: {s}");
}
#[test]
fn null_task_json() {
let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
let ud = &lines as *const _ as *mut c_void;
let h = aacode_task_start(std::ptr::null(), Some(capture_cb), ud);
let result = unsafe {
let p = aacode_task_wait(h);
let s = CStr::from_ptr(p).to_str().unwrap().to_string();
aacode_free_string(p);
aacode_task_free(h);
s
};
assert!(result.contains("null task_json"));
}
#[test]
fn wait_null_handle_is_safe() {
let p = aacode_task_wait(std::ptr::null_mut());
let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
aacode_free_string(p);
assert!(s.contains("error"));
}
#[test]
fn cancel_null_handle_is_safe() {
aacode_task_cancel(std::ptr::null_mut());
}
#[test]
fn free_null_is_safe() {
aacode_task_free(std::ptr::null_mut());
aacode_free_string(std::ptr::null_mut());
}
#[test]
fn session_guard_rejects_duplicate() {
let g1 = SessionGuard::try_acquire("p::s1".to_string());
assert!(g1.is_some());
let g2 = SessionGuard::try_acquire("p::s1".to_string());
assert!(g2.is_none(), "duplicate session must be rejected");
drop(g1);
let g3 = SessionGuard::try_acquire("p::s1".to_string());
assert!(g3.is_some(), "released session must be reacquirable");
}
#[test]
fn cancel_aborts_task_and_releases_guard() {
let key = format!("/tmp/p::cancel_{}", uuid::Uuid::new_v4().simple());
let (tx, rx) = mpsc::channel::<String>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
let cancel = Arc::new(AtomicBool::new(false));
let cancel2 = cancel.clone();
let key2 = key.clone();
let join = TOKIO_RT.spawn(async move {
let _g = SessionGuard::try_acquire(key2).unwrap();
let _ = ready_tx.send(());
futures::future::pending::<()>().await;
});
ready_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("task should acquire the guard");
assert!(
SessionGuard::try_acquire(key.clone()).is_none(),
"guard held while running"
);
let handle = Box::into_raw(Box::new(AacodeTask::new(
cancel2,
rx,
tx,
Some(join),
None,
None,
)));
aacode_task_cancel(handle);
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(
SessionGuard::try_acquire(key.clone()).is_some(),
"guard must be released after cancel"
);
aacode_task_free(handle);
}
#[test]
fn cancel_releases_session_synchronously() {
let key = format!("/tmp/p::sync_{}", uuid::Uuid::new_v4().simple());
let (tx, rx) = mpsc::channel::<String>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
let cancel = Arc::new(AtomicBool::new(false));
let key2 = key.clone();
let join = TOKIO_RT.spawn(async move {
let _g = SessionGuard::try_acquire(key2).unwrap();
let _ = ready_tx.send(());
let end = std::time::Instant::now() + std::time::Duration::from_millis(1000);
while std::time::Instant::now() < end {
std::hint::spin_loop();
}
});
ready_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("task should acquire the guard");
assert!(SessionGuard::try_acquire(key.clone()).is_none());
let handle = Box::into_raw(Box::new(AacodeTask::new(
cancel,
rx,
tx,
Some(join),
Some(key.clone()),
None,
)));
aacode_task_cancel(handle);
assert!(
SessionGuard::try_acquire(key.clone()).is_some(),
"cancel must release the session synchronously"
);
aacode_task_free(handle);
}
#[test]
fn concurrent_tasks_same_session_rejected() {
let _hold = SessionGuard::try_acquire("/tmp/proj::sess_dup".to_string()).unwrap();
let (out, _events) = run_task(
r#"{"task":"do x","project_path":"/tmp/proj","session_id":"sess_dup","model":{"name":"m","api_key":"k"}}"#,
);
assert!(out.contains("already has a task running"), "got: {out}");
}
#[test]
fn list_sessions_ffi() {
let d = std::env::temp_dir().join(format!("ffi_ls_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&d).unwrap();
{
let mut sm = SessionManager::new(&d);
sm.create_session("task", None).unwrap();
}
let c = CString::new(d.to_string_lossy().to_string()).unwrap();
let ptr = aacode_list_sessions(c.as_ptr());
let s = unsafe { CStr::from_ptr(ptr).to_str().unwrap().to_string() };
aacode_free_string(ptr);
assert!(s.contains("\"success\":true"));
}
fn mkdirs(p: &std::path::Path) -> String {
std::fs::create_dir_all(p).unwrap();
p.to_string_lossy().to_string()
}
fn tmp_root(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("ffi_root_{tag}_{}", uuid::Uuid::new_v4().simple()))
}
#[test]
fn no_skills_config_keeps_project_jail() {
let base = tmp_root("nocfg");
let sb = mkdirs(&base.join("sb"));
let pp = mkdirs(&base.join("sb").join("proj"));
let (root, rel) = resolve_shell_root(&sb, &pp, None);
assert_eq!(root, pp);
assert!(rel.is_none());
}
#[test]
fn project_inside_sandbox_widens_and_positions_cwd() {
let base = tmp_root("inside");
let sb = mkdirs(&base.join("sb"));
let sk = mkdirs(&base.join("sb").join("skills"));
let pp = mkdirs(&base.join("sb").join("a").join("b"));
let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
assert_eq!(root, sb);
let rel = rel.expect("must reposition cwd");
assert_eq!(
std::path::PathBuf::from(rel),
std::path::PathBuf::from("a").join("b")
);
}
#[test]
fn project_equal_to_sandbox_user_dir_inside_no_widening() {
let base = tmp_root("equal_in");
let sb = mkdirs(&base.join("sb"));
let sk = mkdirs(&base.join("sb").join("skills"));
let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
assert_eq!(root, sb);
assert!(rel.is_none());
}
#[test]
fn project_equal_to_sandbox_user_dir_outside_widens() {
let base = tmp_root("equal_out");
let sb = mkdirs(&base.join("sb"));
let sk = mkdirs(&base.join("skills"));
let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
let base_c = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
assert_eq!(root, base_c.to_string_lossy());
assert_eq!(rel.as_deref(), Some("sb"));
}
#[test]
fn sibling_prefix_dir_is_not_inside_sandbox() {
let base = tmp_root("sibling");
let sb = mkdirs(&base.join("sb"));
let pp = mkdirs(&base.join("sb2").join("proj"));
let sk = mkdirs(&base.join("sb2").join("proj").join("skills"));
let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
assert_eq!(root, pp, "sibling project must keep its own jail");
assert!(rel.is_none());
}
#[test]
fn project_outside_sandbox_keeps_own_jail() {
let base = tmp_root("outside");
let sb = mkdirs(&base.join("sb"));
let pp = mkdirs(&base.join("elsewhere").join("proj"));
let sk = mkdirs(&base.join("elsewhere").join("proj").join("skills"));
let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
assert_eq!(root, pp);
assert!(rel.is_none());
}
#[cfg(unix)]
#[test]
fn symlinked_project_path_still_recognized() {
let base = tmp_root("symlink");
let real_sb = mkdirs(&base.join("real_sb"));
mkdirs(&base.join("real_sb").join("proj"));
let sk = mkdirs(&base.join("real_sb").join("skills"));
let link_sb = base.join("link_sb");
std::os::unix::fs::symlink(&real_sb, &link_sb).unwrap();
let pp_via_link = link_sb.join("proj").to_string_lossy().to_string();
let (root, rel) = resolve_shell_root(&real_sb, &pp_via_link, Some(&sk));
assert_eq!(root, real_sb, "symlinked project must widen to sandbox");
assert_eq!(rel.as_deref(), Some("proj"));
}
fn call_str(f: impl FnOnce() -> *mut c_char) -> String {
let p = f();
let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
aacode_free_string(p);
s
}
#[test]
fn session_version_is_one() {
assert_eq!(aacode_session_version(), SCHEMA_VERSION);
assert_eq!(aacode_session_version(), 1);
}
#[test]
fn session_lifecycle_roundtrip() {
let proj = mkdirs(&tmp_root("sess_life"));
let pp = CString::new(proj).unwrap();
let sid = CString::new("sess_a").unwrap();
let title = CString::new("My Session").unwrap();
let r = call_str(|| aacode_session_ensure(pp.as_ptr(), sid.as_ptr(), title.as_ptr()));
assert!(r.contains("\"success\":true"), "ensure: {r}");
let msgs = r#"[{"role":"user","content":"hello","timestamp":"1700000001"},{"role":"assistant","content":"hi","timestamp":"1700000002"}]"#;
let mj = CString::new(msgs).unwrap();
let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
assert!(r.contains("\"success\":true"), "append: {r}");
let new_title = CString::new("Renamed").unwrap();
let r = call_str(|| aacode_session_rename(pp.as_ptr(), sid.as_ptr(), new_title.as_ptr()));
assert!(r.contains("\"success\":true"), "rename: {r}");
let r = call_str(|| aacode_session_list(pp.as_ptr()));
assert!(r.contains("\"success\":true"), "list: {r}");
assert!(r.contains("sess_a"));
assert!(r.contains("Renamed"));
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
assert!(r.contains("\"success\":true"), "messages: {r}");
assert!(r.contains("\"total\":2"));
assert!(r.contains("hello"));
let r = call_str(|| aacode_session_touch(pp.as_ptr(), sid.as_ptr()));
assert!(r.contains("\"success\":true"), "touch: {r}");
let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
assert!(r.contains("\"success\":true"), "delete: {r}");
let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
assert!(
r.contains("\"success\":true"),
"delete again (idempotent): {r}"
);
}
#[test]
fn session_append_bad_json_errors() {
let proj = mkdirs(&tmp_root("sess_bad"));
let pp = CString::new(proj).unwrap();
let sid = CString::new("s1").unwrap();
let bad = CString::new("not json").unwrap();
let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), bad.as_ptr()));
assert!(
r.contains("\"success\":false"),
"bad msgs_json must error: {r}"
);
assert!(r.contains("bad msgs_json"));
}
#[test]
fn session_messages_pagination() {
let proj = mkdirs(&tmp_root("sess_page"));
let pp = CString::new(proj.clone()).unwrap();
let sid = CString::new("s1").unwrap();
{
let mut sm = SessionManager::new(std::path::Path::new(&proj));
sm.ensure_session("s1", "").unwrap();
for i in 0..5 {
sm.append_session_messages(
"s1",
vec![SessionMessage {
role: "user".to_string(),
content: format!("m{i}"),
timestamp: format!("17000000{i:02}"),
tokens: 1,
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
}],
)
.unwrap();
}
}
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 2));
assert!(r.contains("\"total\":5"), "total: {r}");
assert!(
r.contains("m4") && r.contains("m3") && !r.contains("m0"),
"page0: {r}"
);
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 2, 2));
assert!(
r.contains("m2") && r.contains("m1") && !r.contains("m3"),
"page1: {r}"
);
}
#[test]
fn session_messages_null_args_error() {
let r = call_str(|| aacode_session_messages(std::ptr::null(), std::ptr::null(), 0, 50));
assert!(r.contains("\"success\":false"), "null args: {r}");
}
#[test]
fn session_ffi_rejects_path_traversal_sid() {
let proj = mkdirs(&tmp_root("sess_trav"));
let pp = CString::new(proj).unwrap();
let evil = CString::new("../escape").unwrap();
let r = call_str(|| aacode_session_ensure(pp.as_ptr(), evil.as_ptr(), std::ptr::null()));
assert!(
r.contains("\"success\":false"),
"path traversal must be rejected: {r}"
);
assert!(r.contains("invalid session_id"));
let r = call_str(|| aacode_session_messages(pp.as_ptr(), evil.as_ptr(), 0, 50));
assert!(r.contains("\"success\":false"));
let r = call_str(|| aacode_session_delete(pp.as_ptr(), evil.as_ptr()));
assert!(r.contains("\"success\":false"));
}
#[test]
fn session_messages_limit_zero_and_beyond_total() {
let proj = mkdirs(&tmp_root("sess_l0"));
let pp = CString::new(proj.clone()).unwrap();
let sid = CString::new("s1").unwrap();
{
let mut sm = SessionManager::new(std::path::Path::new(&proj));
sm.ensure_session("s1", "").unwrap();
sm.append_session_messages(
"s1",
vec![SessionMessage {
role: "user".to_string(),
content: "hi".to_string(),
timestamp: "1".to_string(),
tokens: 1,
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
}],
)
.unwrap();
}
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 0));
assert!(r.contains("\"total\":1"), "limit=0 total: {r}");
assert!(
!r.contains("\"hi\""),
"limit=0 must return no messages: {r}"
);
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 5, 10));
assert!(r.contains("\"total\":1"));
assert!(
!r.contains("\"hi\""),
"offset beyond total must be empty: {r}"
);
}
#[test]
fn session_unicode_content_roundtrip() {
let proj = mkdirs(&tmp_root("sess_uni"));
let pp = CString::new(proj).unwrap();
let sid = CString::new("s1").unwrap();
let msgs = r#"[{"role":"user","content":"你好,世界 🌍","timestamp":"1700000001"}]"#;
let mj = CString::new(msgs).unwrap();
let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
assert!(r.contains("\"success\":true"), "append unicode: {r}");
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
assert!(r.contains("你好,世界"), "unicode content preserved: {r}");
}
#[test]
fn session_append_ignores_segments_field() {
let proj = mkdirs(&tmp_root("sess_segs"));
let pp = CString::new(proj).unwrap();
let sid = CString::new("s1").unwrap();
let msgs = r#"[
{"role":"assistant","content":"ok","timestamp":"1700000001",
"segments":[
{"type":"thinking","content":"think","created_at":"1700000001.001"},
{"type":"action","name":"run_shell","content":"{}","created_at":"1700000001.002"}
]}
]"#;
let mj = CString::new(msgs).unwrap();
let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
assert!(r.contains("\"success\":true"), "append: {r}");
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
assert!(r.contains("\"ok\""), "content preserved: {r}");
assert!(
!r.contains("segments"),
"legacy segments must be dropped, not persisted: {r}"
);
}
#[test]
fn session_append_without_segments_still_works() {
let proj = mkdirs(&tmp_root("sess_noseg"));
let pp = CString::new(proj).unwrap();
let sid = CString::new("s1").unwrap();
let msgs = r#"[{"role":"user","content":"hi","timestamp":"1700000001"}]"#;
let mj = CString::new(msgs).unwrap();
let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
assert!(
r.contains("\"success\":true"),
"append without segments: {r}"
);
let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
assert!(
r.contains("\"hi\""),
"message without segments still read: {r}"
);
assert!(
!r.contains("segments"),
"segments are never emitted in the wire: {r}"
);
}
#[test]
fn session_list_empty_project() {
let proj = mkdirs(&tmp_root("sess_empty"));
let pp = CString::new(proj).unwrap();
let r = call_str(|| aacode_session_list(pp.as_ptr()));
assert!(r.contains("\"success\":true"));
assert!(r.contains("\"sessions\":[]"), "empty project: {r}");
}
}
#[cfg(test)]
mod surface_ffi_tests {
use super::*;
#[test]
fn register_surface_ops_rejects_null() {
assert_eq!(
unsafe { aacode_browser_register_surface_ops(std::ptr::null()) },
-1
);
}
}
#[cfg(all(test, feature = "pty", unix))]
mod pty_ffi_tests {
use super::*;
use base64::Engine as _;
fn take(ptr: *mut c_char) -> serde_json::Value {
assert!(!ptr.is_null());
let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string();
aacode_free_string(ptr);
serde_json::from_str(&s).unwrap()
}
#[test]
fn terminal_ffi_open_write_read_list_close() {
let shell = CString::new("/bin/sh").unwrap();
let cwd = CString::new(".").unwrap();
let open = take(aacode_terminal_open(shell.as_ptr(), cwd.as_ptr(), 80, 24));
assert_eq!(open["ok"], true, "{open:?}");
let id = open["id"].as_str().unwrap().to_string();
let idc = CString::new(id.clone()).unwrap();
let cmd = b"printf 'FFI_OK\\n'\n";
let w = take(aacode_terminal_write(idc.as_ptr(), cmd.as_ptr(), cmd.len()));
assert_eq!(w["ok"], true, "{w:?}");
let mut cursor = 0u64;
let mut all = String::new();
for _ in 0..200 {
let r = take(aacode_terminal_read(idc.as_ptr(), cursor, 4096));
if !r["ok"].as_bool().unwrap_or(false) {
break;
}
cursor = r["cursor"].as_u64().unwrap_or(cursor);
if let Some(b64) = r["data_b64"].as_str() {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
all.push_str(&String::from_utf8_lossy(&bytes));
}
if all.contains("FFI_OK") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(all.contains("FFI_OK"), "output: {all:?}");
let list = take(aacode_terminal_list());
assert!(list["sessions"]
.as_array()
.unwrap()
.iter()
.any(|s| s["id"].as_str() == Some(id.as_str())));
let resized = take(aacode_terminal_resize(idc.as_ptr(), 100, 40));
assert_eq!(resized["ok"], true);
let closed = take(aacode_terminal_close(idc.as_ptr()));
assert_eq!(closed["ok"], true);
}
}