1#![allow(clippy::not_unsafe_ptr_arg_deref)]
30
31use crate::config::AgentConfig;
32use crate::runtime::{AgentRuntime, TOKIO_RT};
33use crate::session::{valid_session_id, SessionManager, SessionMessage, SCHEMA_VERSION};
34use crate::stream::CallbackSink;
35use serde_json::json;
36use std::ffi::{c_char, c_void, CStr, CString};
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::mpsc;
39use std::sync::{Arc, Mutex, OnceLock};
40
41pub type StreamCallback = extern "C" fn(*const c_char, *mut c_void);
45
46pub struct AacodeTask {
48 cancel: Arc<AtomicBool>,
49 result_rx: mpsc::Receiver<String>,
50 tx: mpsc::Sender<String>,
52 join: Mutex<Option<tokio::task::JoinHandle<()>>>,
54 session_key: Option<String>,
59 fs_cancel: Option<Arc<AtomicBool>>,
63}
64
65impl AacodeTask {
66 fn new(
67 cancel: Arc<AtomicBool>,
68 result_rx: mpsc::Receiver<String>,
69 tx: mpsc::Sender<String>,
70 join: Option<tokio::task::JoinHandle<()>>,
71 session_key: Option<String>,
72 fs_cancel: Option<Arc<AtomicBool>>,
73 ) -> Self {
74 AacodeTask {
75 cancel,
76 result_rx,
77 tx,
78 join: Mutex::new(join),
79 session_key,
80 fs_cancel,
81 }
82 }
83}
84
85struct SendUserData(*mut c_void);
88unsafe impl Send for SendUserData {}
92unsafe impl Sync for SendUserData {}
93
94impl SendUserData {
95 #[inline]
98 fn ptr(&self) -> *mut c_void {
99 self.0
100 }
101}
102
103static RUNNING_SESSIONS: OnceLock<Mutex<std::collections::HashMap<String, Arc<()>>>> =
107 OnceLock::new();
108
109fn running_sessions() -> &'static Mutex<std::collections::HashMap<String, Arc<()>>> {
110 RUNNING_SESSIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
111}
112
113fn release_session(key: &str) {
117 running_sessions()
118 .lock()
119 .unwrap_or_else(|e| e.into_inner())
120 .remove(key);
121}
122
123struct SessionGuard {
127 key: String,
128 token: Arc<()>,
129}
130
131impl SessionGuard {
132 fn try_acquire(key: String) -> Option<Self> {
133 let mut map = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
134 if map.contains_key(&key) {
135 return None;
136 }
137 let token = Arc::new(());
138 map.insert(key.clone(), token.clone());
139 Some(SessionGuard { key, token })
140 }
141}
142
143impl Drop for SessionGuard {
144 fn drop(&mut self) {
145 let mut map = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
146 if let Some(cur) = map.get(&self.key) {
147 if Arc::ptr_eq(cur, &self.token) {
148 map.remove(&self.key);
149 }
150 }
151 }
152}
153
154unsafe fn cstr<'a>(ptr: *const c_char) -> Option<&'a str> {
156 if ptr.is_null() {
157 return None;
158 }
159 CStr::from_ptr(ptr).to_str().ok()
160}
161
162fn to_c_string(s: String) -> *mut c_char {
164 CString::new(s)
165 .unwrap_or_else(|_| CString::new("").unwrap())
166 .into_raw()
167}
168
169fn guard_cchar(f: impl FnOnce() -> *mut c_char) -> *mut c_char {
178 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
179 Ok(ptr) => ptr,
180 Err(_) => to_c_string(
181 serde_json::json!({"success": false, "error": "internal panic"}).to_string(),
182 ),
183 }
184}
185
186fn resolve_shell_root(
210 sandbox: &str,
211 project: &str,
212 user_dir: Option<&str>,
213) -> (String, Option<String>) {
214 let Some(user_dir) = user_dir else {
215 return (project.to_string(), None);
216 };
217 let canon = |s: &str| std::fs::canonicalize(s).unwrap_or_else(|_| std::path::PathBuf::from(s));
218 let sandbox_c = canon(sandbox);
219 let project_c = canon(project);
220 let user_dir_c = canon(user_dir);
221
222 match project_c.strip_prefix(&sandbox_c) {
224 Ok(rel) if !rel.as_os_str().is_empty() => {
225 return (sandbox.to_string(), Some(rel.to_string_lossy().to_string()));
226 }
227 _ => {}
228 }
229
230 if user_dir_c.strip_prefix(&project_c).is_ok() {
232 return (project.to_string(), None);
233 }
234
235 for ancestor in project_c.ancestors().skip(1) {
243 if ancestor.parent().is_none() {
244 break;
245 }
246 if user_dir_c.strip_prefix(ancestor).is_ok() {
247 if let Ok(rel) = project_c.strip_prefix(ancestor) {
248 if !rel.as_os_str().is_empty() {
249 return (
250 ancestor.to_string_lossy().to_string(),
251 Some(rel.to_string_lossy().to_string()),
252 );
253 }
254 }
255 }
256 }
257
258 (project.to_string(), None)
259}
260
261fn build_runtime(
264 config: &AgentConfig,
265 project_path: &str,
266) -> Result<(AgentRuntime, Option<Arc<AtomicBool>>), String> {
267 if let Some(sdk) = fastshell::sdk::try_get_sdk_instance() {
268 let sandbox = {
269 let guard = sdk.lock().unwrap_or_else(|e| e.into_inner());
270 guard.vfs_root()
271 };
272 let pp = if project_path == "." {
273 sandbox.clone()
274 } else {
275 project_path.to_string()
276 };
277 let (shell_root, cd_rel) =
279 resolve_shell_root(&sandbox, &pp, config.skills.user_dir.as_deref());
280 let mut cfg = config.clone();
284 if let Some(ref user_dir) = cfg.skills.user_dir {
285 if let Ok(rel) = std::path::Path::new(user_dir).strip_prefix(&shell_root) {
286 if !rel.as_os_str().is_empty() {
287 cfg.skills.vfs_skills_dir = Some(format!("/{}", rel.to_string_lossy()));
288 }
289 }
290 }
291 let fs_arc: Arc<Mutex<fastshell::Fastshell>> =
292 Arc::new(Mutex::new(fastshell::Fastshell::new()));
293 {
294 let mut ours = fs_arc.lock().unwrap();
295 let mut fscfg = fastshell::Config::default();
296 fscfg.sandbox_path = shell_root.clone();
297 fscfg.python_enabled = true;
298 fscfg.allow_subprocess = false; fscfg.network_ask_permission = false;
300 if let Err(e) = ours.init(fscfg) {
301 return Err(e.to_string());
302 }
303 if let Some(rel) = cd_rel {
304 let r = ours.execute(&format!("cd '{}'", rel.replace('\'', "'\\''")));
307 if r.exit_code != 0 {
308 return Err(format!("cd to project failed: {}", r.stderr));
309 }
310 }
311 }
312 let fs_cancel = fs_arc
313 .lock()
314 .unwrap_or_else(|e| e.into_inner())
315 .cancel_handle();
316 Ok((
317 AgentRuntime::with_fastshell(cfg, std::path::PathBuf::from(pp), fs_arc),
318 Some(fs_cancel),
319 ))
320 } else {
321 AgentRuntime::init(config.clone(), std::path::PathBuf::from(project_path))
322 .map(|rt| (rt, None))
323 .map_err(|e| e.to_string())
324 }
325}
326
327#[no_mangle]
330pub extern "C" fn aacode_task_start(
331 task_json: *const c_char,
332 cb: Option<StreamCallback>,
333 userdata: *mut c_void,
334) -> *mut AacodeTask {
335 let (tx, rx) = mpsc::channel::<String>();
337 let cancel = Arc::new(AtomicBool::new(false));
338
339 let ud = SendUserData(userdata);
340 let emit = move |line: &str| {
341 if let Some(cb) = cb {
342 if let Ok(cs) = CString::new(line) {
343 cb(cs.as_ptr(), ud.ptr());
344 }
345 }
346 };
347
348 let input = match unsafe { cstr(task_json) } {
349 Some(s) => s.to_string(),
350 None => {
351 let msg = "null task_json";
352 emit(&json!({"type": "error", "message": msg}).to_string());
353 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
354 return Box::into_raw(Box::new(AacodeTask::new(
355 cancel,
356 rx,
357 tx.clone(),
358 None,
359 None,
360 None,
361 )));
362 }
363 };
364
365 let v: serde_json::Value = match serde_json::from_str(&input) {
366 Ok(v) => v,
367 Err(e) => {
368 let msg = format!("bad task_json: {e}");
369 emit(&json!({"type": "error", "message": msg}).to_string());
370 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
371 return Box::into_raw(Box::new(AacodeTask::new(
372 cancel,
373 rx,
374 tx.clone(),
375 None,
376 None,
377 None,
378 )));
379 }
380 };
381 let task = v
382 .get("task")
383 .and_then(|x| x.as_str())
384 .unwrap_or("")
385 .to_string();
386 if task.is_empty() {
387 let msg = "missing task";
388 emit(&json!({"type": "error", "message": msg}).to_string());
389 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
390 return Box::into_raw(Box::new(AacodeTask::new(
391 cancel,
392 rx,
393 tx.clone(),
394 None,
395 None,
396 None,
397 )));
398 }
399 let project_path = v
400 .get("project_path")
401 .and_then(|x| x.as_str())
402 .unwrap_or(".")
403 .to_string();
404 let session_id = v
405 .get("session_id")
406 .and_then(|x| x.as_str())
407 .map(|s| s.to_string());
408
409 let session_key: Option<String> = session_id
412 .as_ref()
413 .map(|sid| format!("{project_path}::{sid}"));
414 let session_guard = match &session_key {
415 Some(key) => match SessionGuard::try_acquire(key.clone()) {
416 Some(g) => Some(g),
417 None => {
418 let sid = session_id.as_deref().unwrap_or("");
419 let msg = format!("session '{sid}' already has a task running");
420 emit(&json!({"type": "error", "message": msg}).to_string());
421 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
422 return Box::into_raw(Box::new(AacodeTask::new(
423 cancel,
424 rx,
425 tx.clone(),
426 None,
427 None,
428 None,
429 )));
430 }
431 },
432 None => None,
433 };
434
435 let mut config: AgentConfig = serde_json::from_value(v).unwrap_or_default();
436 config.apply_env();
437
438 let (rt, fs_cancel) = match build_runtime(&config, &project_path) {
439 Ok(x) => x,
440 Err(e) => {
441 emit(&json!({"type": "error", "message": e}).to_string());
442 let _ = tx.send(json!({"status": "error", "error": e}).to_string());
443 return Box::into_raw(Box::new(AacodeTask::new(
444 cancel,
445 rx,
446 tx.clone(),
447 None,
448 None,
449 None,
450 )));
451 }
452 };
453
454 let cancel2 = cancel.clone();
455 let tx_for_handle = tx.clone();
456 let join = TOKIO_RT.spawn(async move {
457 let _guard = session_guard;
459 use futures::FutureExt;
464 let result = std::panic::AssertUnwindSafe(rt.run_task(
465 &task,
466 session_id.as_deref(),
467 &CallbackSink::new(Box::new(emit)),
468 &cancel2,
469 ))
470 .catch_unwind()
471 .await;
472 let json = match result {
473 Ok(Ok(res)) => res.to_result_json().to_string(),
474 Ok(Err(e)) => json!({"status": "error", "error": e.to_string()}).to_string(),
475 Err(_) => {
476 json!({"status": "error", "error": "agent panicked (internal error)"}).to_string()
477 }
478 };
479 let _ = tx.send(json);
480 });
481
482 Box::into_raw(Box::new(AacodeTask::new(
483 cancel,
484 rx,
485 tx_for_handle,
486 Some(join),
487 session_key,
488 fs_cancel,
489 )))
490}
491
492#[no_mangle]
495pub extern "C" fn aacode_task_wait(handle: *mut AacodeTask) -> *mut c_char {
496 if handle.is_null() {
498 return to_c_string(json!({"status": "error", "error": "null handle"}).to_string());
499 }
500 let h = unsafe { &*handle };
501 let result = h.result_rx.recv().unwrap_or_else(|_| {
502 json!({"status": "error", "error": "task was dropped before completion"}).to_string()
503 });
504 to_c_string(result)
505}
506
507#[no_mangle]
509pub extern "C" fn aacode_task_cancel(handle: *mut AacodeTask) {
510 if handle.is_null() {
512 return;
513 }
514 let h = unsafe { &*handle };
515 h.cancel.store(true, Ordering::SeqCst);
516 if let Some(c) = &h.fs_cancel {
520 c.store(true, Ordering::SeqCst);
521 }
522 if let Some(join) = h.join.lock().unwrap_or_else(|e| e.into_inner()).take() {
524 join.abort();
525 }
526 if let Some(k) = &h.session_key {
530 release_session(k);
531 }
532 let _ = h.tx.send(json!({"status": "cancelled"}).to_string());
534}
535
536#[no_mangle]
538pub extern "C" fn aacode_task_free(handle: *mut AacodeTask) {
539 if handle.is_null() {
541 return;
542 }
543 {
546 let h = unsafe { &*handle };
547 if let Some(join) = h.join.lock().unwrap_or_else(|e| e.into_inner()).take() {
548 join.abort();
549 if let Some(c) = &h.fs_cancel {
550 c.store(true, Ordering::SeqCst);
551 }
552 }
553 if let Some(k) = &h.session_key {
554 release_session(k);
555 }
556 }
557 unsafe {
558 drop(Box::from_raw(handle));
559 }
560}
561
562#[no_mangle]
565pub extern "C" fn aacode_validate_api_key(config_json: *const c_char) -> *mut c_char {
566 guard_cchar(|| {
568 let input = match unsafe { cstr(config_json) } {
569 Some(s) => s.to_string(),
570 None => {
571 return to_c_string(json!({"valid": false, "error": "null config"}).to_string())
572 }
573 };
574 let mut config: AgentConfig = serde_json::from_str(&input).unwrap_or_default();
575 config.apply_env();
576 let client = crate::llm::build_client(&config.model);
577 let handle = TOKIO_RT.handle().clone();
578 let out = std::thread::scope(|s| {
579 s.spawn(
580 move || match handle.block_on(async { client.validate().await }) {
581 Ok(()) => json!({"valid": true}),
582 Err(e) => json!({"valid": false, "error": e.to_string()}),
583 },
584 )
585 .join()
587 .unwrap_or_else(|_| json!({"valid": false, "error": "validation thread panicked"}))
588 });
589 to_c_string(out.to_string())
590 })
591}
592
593#[no_mangle]
595pub extern "C" fn aacode_list_sessions(project_path: *const c_char) -> *mut c_char {
596 guard_cchar(|| {
598 let pp = match unsafe { cstr(project_path) } {
599 Some(s) => s.to_string(),
600 None => {
601 return to_c_string(json!({"success": false, "error": "null path"}).to_string())
602 }
603 };
604 let sm = SessionManager::new(std::path::Path::new(&pp));
605 let sessions: Vec<serde_json::Value> = sm
606 .list_sessions()
607 .into_iter()
608 .map(|s| {
609 json!({
610 "session_id": s.session_id,
611 "title": s.title,
612 "created_at": s.created_at,
613 "last_activity": s.last_activity,
614 "total_messages": s.total_messages,
615 "status": s.status,
616 })
617 })
618 .collect();
619 to_c_string(json!({"success": true, "sessions": sessions}).to_string())
620 })
621}
622
623#[no_mangle]
625pub extern "C" fn aacode_get_session_messages(
626 project_path: *const c_char,
627 session_id: *const c_char,
628) -> *mut c_char {
629 guard_cchar(|| {
631 let pp = unsafe { cstr(project_path) }.unwrap_or("").to_string();
632 let sid = unsafe { cstr(session_id) }.unwrap_or("").to_string();
633 let sm = SessionManager::new(std::path::Path::new(&pp));
634 let msgs = sm.read_session_messages(&sid);
635 let arr: Vec<serde_json::Value> = msgs
636 .iter()
637 .map(|m| {
638 json!({
639 "role": m.role,
640 "content": m.content,
641 "timestamp": m.timestamp,
642 "tool_calls": m.tool_calls,
643 "tool_call_id": m.tool_call_id,
644 "reasoning_content": m.reasoning_content,
645 })
646 })
647 .collect();
648 to_c_string(json!({"success": true, "messages": arr}).to_string())
649 })
650}
651
652static SESSION_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
658
659fn message_to_json(m: &SessionMessage) -> serde_json::Value {
661 json!({
662 "role": m.role,
663 "content": m.content,
664 "timestamp": m.timestamp,
665 "tokens": m.tokens,
666 "tool_calls": m.tool_calls,
667 "tool_call_id": m.tool_call_id,
668 "reasoning_content": m.reasoning_content,
669 })
670}
671
672fn session_err(msg: &str) -> *mut c_char {
674 to_c_string(json!({"success": false, "error": msg}).to_string())
675}
676
677fn session_id_args(
680 project_path: *const c_char,
681 session_id: *const c_char,
682) -> Result<(String, String), String> {
683 let pp = unsafe { cstr(project_path) }
684 .ok_or_else(|| "null project_path".to_string())?
685 .to_string();
686 let sid = unsafe { cstr(session_id) }
687 .ok_or_else(|| "null session_id".to_string())?
688 .to_string();
689 if !valid_session_id(&sid) {
690 return Err("invalid session_id".to_string());
691 }
692 Ok((pp, sid))
693}
694
695#[no_mangle]
697pub extern "C" fn aacode_session_version() -> u32 {
698 SCHEMA_VERSION
699}
700
701#[no_mangle]
703pub extern "C" fn aacode_session_list(project_path: *const c_char) -> *mut c_char {
704 guard_cchar(|| {
705 let pp = match unsafe { cstr(project_path) } {
706 Some(s) => s.to_string(),
707 None => return session_err("null project_path"),
708 };
709 let sm = SessionManager::new(std::path::Path::new(&pp));
710 let sessions: Vec<serde_json::Value> = sm
711 .list_sessions()
712 .into_iter()
713 .map(|s| {
714 json!({
715 "session_id": s.session_id,
716 "title": s.title,
717 "created_at": s.created_at,
718 "last_activity": s.last_activity,
719 "total_messages": s.total_messages,
720 "total_tokens": s.total_tokens,
721 "status": s.status,
722 })
723 })
724 .collect();
725 to_c_string(json!({"success": true, "sessions": sessions}).to_string())
726 })
727}
728
729#[no_mangle]
731pub extern "C" fn aacode_session_messages(
732 project_path: *const c_char,
733 session_id: *const c_char,
734 offset: u32,
735 limit: u32,
736) -> *mut c_char {
737 guard_cchar(|| {
738 let (pp, sid) = match session_id_args(project_path, session_id) {
739 Ok(v) => v,
740 Err(e) => return session_err(&e),
741 };
742 let sm = SessionManager::new(std::path::Path::new(&pp));
743 let all = sm.read_session_messages(&sid);
744 let total = all.len();
745 let offset = offset as usize;
746 let limit = limit as usize;
747 let end = total.saturating_sub(offset);
748 let start = end.saturating_sub(limit);
749 let slice: Vec<serde_json::Value> = if start < end {
750 all[start..end].iter().map(message_to_json).collect()
751 } else {
752 Vec::new()
753 };
754 to_c_string(
755 json!({
756 "success": true,
757 "total": total,
758 "offset": offset,
759 "limit": limit,
760 "messages": slice,
761 })
762 .to_string(),
763 )
764 })
765}
766
767#[no_mangle]
769pub extern "C" fn aacode_session_ensure(
770 project_path: *const c_char,
771 session_id: *const c_char,
772 title: *const c_char,
773) -> *mut c_char {
774 guard_cchar(|| {
775 let (pp, sid) = match session_id_args(project_path, session_id) {
776 Ok(v) => v,
777 Err(e) => return session_err(&e),
778 };
779 let title = unsafe { cstr(title) }.unwrap_or("").to_string();
780 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
781 let mut sm = SessionManager::new(std::path::Path::new(&pp));
782 match sm.ensure_session(&sid, &title) {
783 Ok(()) => to_c_string(json!({"success": true}).to_string()),
784 Err(e) => session_err(&e.to_string()),
785 }
786 })
787}
788
789#[no_mangle]
791pub extern "C" fn aacode_session_rename(
792 project_path: *const c_char,
793 session_id: *const c_char,
794 title: *const c_char,
795) -> *mut c_char {
796 guard_cchar(|| {
797 let (pp, sid) = match session_id_args(project_path, session_id) {
798 Ok(v) => v,
799 Err(e) => return session_err(&e),
800 };
801 let title = unsafe { cstr(title) }.unwrap_or("").to_string();
802 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
803 let mut sm = SessionManager::new(std::path::Path::new(&pp));
804 match sm.rename_session(&sid, &title) {
805 Ok(()) => to_c_string(json!({"success": true}).to_string()),
806 Err(e) => session_err(&e.to_string()),
807 }
808 })
809}
810
811#[no_mangle]
813pub extern "C" fn aacode_session_touch(
814 project_path: *const c_char,
815 session_id: *const c_char,
816) -> *mut c_char {
817 guard_cchar(|| {
818 let (pp, sid) = match session_id_args(project_path, session_id) {
819 Ok(v) => v,
820 Err(e) => return session_err(&e),
821 };
822 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
823 let mut sm = SessionManager::new(std::path::Path::new(&pp));
824 match sm.touch_session(&sid) {
825 Ok(()) => to_c_string(json!({"success": true}).to_string()),
826 Err(e) => session_err(&e.to_string()),
827 }
828 })
829}
830
831#[no_mangle]
833pub extern "C" fn aacode_session_delete(
834 project_path: *const c_char,
835 session_id: *const c_char,
836) -> *mut c_char {
837 guard_cchar(|| {
838 let (pp, sid) = match session_id_args(project_path, session_id) {
839 Ok(v) => v,
840 Err(e) => return session_err(&e),
841 };
842 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
843 let mut sm = SessionManager::new(std::path::Path::new(&pp));
844 match sm.delete_session(&sid) {
845 Ok(_) => to_c_string(json!({"success": true}).to_string()),
846 Err(e) => session_err(&e.to_string()),
847 }
848 })
849}
850
851#[no_mangle]
853pub extern "C" fn aacode_session_append(
854 project_path: *const c_char,
855 session_id: *const c_char,
856 msgs_json: *const c_char,
857) -> *mut c_char {
858 guard_cchar(|| {
859 let (pp, sid) = match session_id_args(project_path, session_id) {
860 Ok(v) => v,
861 Err(e) => return session_err(&e),
862 };
863 let msgs_str = match unsafe { cstr(msgs_json) } {
864 Some(s) => s,
865 None => return session_err("null msgs_json"),
866 };
867 let msgs: Vec<SessionMessage> = match serde_json::from_str(msgs_str) {
868 Ok(v) => v,
869 Err(e) => return session_err(&format!("bad msgs_json: {e}")),
870 };
871 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
872 let mut sm = SessionManager::new(std::path::Path::new(&pp));
873 match sm.append_session_messages(&sid, msgs) {
874 Ok(()) => to_c_string(json!({"success": true}).to_string()),
875 Err(e) => session_err(&e.to_string()),
876 }
877 })
878}
879
880#[no_mangle]
888pub unsafe extern "C" fn aacode_browser_register_webview_ops(ops: *const c_void) -> i32 {
889 #[cfg(feature = "browser")]
891 {
892 if ops.is_null() {
893 return -1;
894 }
895 fastbrowser::sdk::ffi::fastbrowser_register_webview_ops(
896 ops as *const fastbrowser::sdk::ffi::FbWebViewOps,
897 )
898 }
899 #[cfg(not(feature = "browser"))]
900 {
901 let _ = ops;
902 -1
903 }
904}
905
906#[no_mangle]
914pub unsafe extern "C" fn aacode_browser_register_surface_ops(ops: *const c_void) -> i32 {
915 #[cfg(feature = "browser")]
917 {
918 if ops.is_null() {
919 return -1;
920 }
921 fastbrowser::sdk::ffi::fastbrowser_register_surface_ops(
922 ops as *const fastbrowser::sdk::ffi::FbSurfaceOps,
923 )
924 }
925 #[cfg(not(feature = "browser"))]
926 {
927 let _ = ops;
928 -1
929 }
930}
931
932#[no_mangle]
935pub extern "C" fn aacode_browser_available() -> i32 {
936 #[cfg(feature = "browser")]
938 {
939 if crate::tools::browser::is_available() {
940 1
941 } else {
942 0
943 }
944 }
945 #[cfg(not(feature = "browser"))]
946 {
947 0
948 }
949}
950
951#[no_mangle]
953pub extern "C" fn aacode_free_string(ptr: *mut c_char) {
954 if ptr.is_null() {
956 return;
957 }
958 unsafe {
959 let _ = CString::from_raw(ptr);
960 }
961}
962
963#[cfg(feature = "pty")]
969static TERMINALS: OnceLock<Mutex<crate::terminal::TerminalManager>> = OnceLock::new();
970
971#[cfg(feature = "pty")]
972fn terminals() -> &'static Mutex<crate::terminal::TerminalManager> {
973 TERMINALS.get_or_init(|| {
974 Mutex::new(crate::terminal::TerminalManager::new(
975 crate::terminal::MAX_SESSIONS,
976 ))
977 })
978}
979
980#[cfg(feature = "pty")]
982#[no_mangle]
983pub extern "C" fn aacode_terminal_open(
984 shell: *const c_char,
985 cwd: *const c_char,
986 cols: u16,
987 rows: u16,
988) -> *mut c_char {
989 let shell = unsafe { cstr(shell) }.unwrap_or("/bin/sh");
990 let cwd = unsafe { cstr(cwd) }.unwrap_or(".");
991 let res = terminals()
992 .lock()
993 .unwrap()
994 .start(shell, std::path::Path::new(cwd), &[], cols, rows);
995 match res {
996 Ok(id) => to_c_string(json!({"ok": true, "id": id}).to_string()),
997 Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
998 }
999}
1000
1001#[cfg(feature = "pty")]
1003#[no_mangle]
1004pub extern "C" fn aacode_terminal_write(
1005 id: *const c_char,
1006 data: *const u8,
1007 len: usize,
1008) -> *mut c_char {
1009 let Some(id) = (unsafe { cstr(id) }) else {
1010 return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1011 };
1012 if data.is_null() {
1013 return to_c_string(json!({"ok": false, "error": "null data"}).to_string());
1014 }
1015 let bytes = unsafe { std::slice::from_raw_parts(data, len) };
1016 match terminals().lock().unwrap().write(id, bytes) {
1017 Ok(()) => to_c_string(json!({"ok": true}).to_string()),
1018 Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1019 }
1020}
1021
1022#[cfg(feature = "pty")]
1024#[no_mangle]
1025pub extern "C" fn aacode_terminal_read(
1026 id: *const c_char,
1027 cursor: u64,
1028 max_bytes: usize,
1029) -> *mut c_char {
1030 use base64::Engine as _;
1031 let Some(id) = (unsafe { cstr(id) }) else {
1032 return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1033 };
1034 match terminals().lock().unwrap().read(id, cursor, max_bytes) {
1035 Ok(r) => {
1036 let b64 = base64::engine::general_purpose::STANDARD.encode(&r.data);
1037 to_c_string(
1038 json!({
1039 "ok": true,
1040 "data_b64": b64,
1041 "cursor": r.cursor,
1042 "eof": r.eof,
1043 "exit_code": r.exit_code,
1044 "truncated": r.truncated,
1045 })
1046 .to_string(),
1047 )
1048 }
1049 Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1050 }
1051}
1052
1053#[cfg(feature = "pty")]
1055#[no_mangle]
1056pub extern "C" fn aacode_terminal_resize(id: *const c_char, cols: u16, rows: u16) -> *mut c_char {
1057 let Some(id) = (unsafe { cstr(id) }) else {
1058 return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1059 };
1060 match terminals().lock().unwrap().resize(id, cols, rows) {
1061 Ok(()) => to_c_string(json!({"ok": true}).to_string()),
1062 Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1063 }
1064}
1065
1066#[cfg(feature = "pty")]
1068#[no_mangle]
1069pub extern "C" fn aacode_terminal_close(id: *const c_char) -> *mut c_char {
1070 let Some(id) = (unsafe { cstr(id) }) else {
1071 return to_c_string(json!({"ok": false, "error": "null id"}).to_string());
1072 };
1073 match terminals().lock().unwrap().close(id) {
1074 Ok(()) => to_c_string(json!({"ok": true}).to_string()),
1075 Err(e) => to_c_string(json!({"ok": false, "error": e}).to_string()),
1076 }
1077}
1078
1079#[cfg(feature = "pty")]
1081#[no_mangle]
1082pub extern "C" fn aacode_terminal_list() -> *mut c_char {
1083 let list = terminals().lock().unwrap().list();
1084 let sessions: Vec<serde_json::Value> = list
1085 .into_iter()
1086 .map(|s| {
1087 json!({
1088 "id": s.id,
1089 "elapsed_ms": s.elapsed_ms,
1090 "eof": s.eof,
1091 "exit_code": s.exit_code,
1092 "buffered": s.buffered,
1093 })
1094 })
1095 .collect();
1096 to_c_string(json!({"ok": true, "sessions": sessions}).to_string())
1097}
1098
1099#[cfg(not(feature = "pty"))]
1102#[no_mangle]
1103pub extern "C" fn aacode_terminal_open(
1104 _shell: *const c_char,
1105 _cwd: *const c_char,
1106 _cols: u16,
1107 _rows: u16,
1108) -> *mut c_char {
1109 to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1110}
1111
1112#[cfg(not(feature = "pty"))]
1113#[no_mangle]
1114pub extern "C" fn aacode_terminal_write(
1115 _id: *const c_char,
1116 _data: *const u8,
1117 _len: usize,
1118) -> *mut c_char {
1119 to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1120}
1121
1122#[cfg(not(feature = "pty"))]
1123#[no_mangle]
1124pub extern "C" fn aacode_terminal_read(
1125 _id: *const c_char,
1126 _cursor: u64,
1127 _max_bytes: usize,
1128) -> *mut c_char {
1129 to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1130}
1131
1132#[cfg(not(feature = "pty"))]
1133#[no_mangle]
1134pub extern "C" fn aacode_terminal_resize(
1135 _id: *const c_char,
1136 _cols: u16,
1137 _rows: u16,
1138) -> *mut c_char {
1139 to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1140}
1141
1142#[cfg(not(feature = "pty"))]
1143#[no_mangle]
1144pub extern "C" fn aacode_terminal_close(_id: *const c_char) -> *mut c_char {
1145 to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1146}
1147
1148#[cfg(not(feature = "pty"))]
1149#[no_mangle]
1150pub extern "C" fn aacode_terminal_list() -> *mut c_char {
1151 to_c_string(json!({"ok": false, "error": "pty feature not enabled"}).to_string())
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157
1158 extern "C" fn capture_cb(line: *const c_char, userdata: *mut c_void) {
1159 if line.is_null() {
1160 return;
1161 }
1162 let l = unsafe { CStr::from_ptr(line).to_str().unwrap_or("").to_string() };
1163 let sink = userdata as *mut Mutex<Vec<String>>;
1164 if !sink.is_null() {
1165 unsafe {
1166 if let Ok(mut v) = (*sink).lock() {
1167 v.push(l);
1168 }
1169 }
1170 }
1171 }
1172
1173 fn run_task(json: &str) -> (String, Vec<String>) {
1174 let c = CString::new(json).unwrap();
1175 let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
1176 let ud = &lines as *const _ as *mut c_void;
1177 let h = aacode_task_start(c.as_ptr(), Some(capture_cb), ud);
1178 let result = unsafe {
1179 let p = aacode_task_wait(h);
1180 let s = CStr::from_ptr(p).to_str().unwrap().to_string();
1181 aacode_free_string(p);
1182 aacode_task_free(h);
1183 s
1184 };
1185 let captured = lines.lock().unwrap().clone();
1186 (result, captured)
1187 }
1188
1189 #[test]
1190 fn run_task_bad_json() {
1191 let (out, events) = run_task("not json");
1192 assert!(out.contains("bad task_json"), "got: {out}");
1193 assert!(
1194 events
1195 .iter()
1196 .any(|l| l.contains(r#""type":"error""#) && l.contains("bad task_json")),
1197 "error event must be emitted: {events:?}"
1198 );
1199 }
1200
1201 #[test]
1202 fn run_task_missing_task() {
1203 let (out, events) = run_task(r#"{"project_path":"/tmp"}"#);
1204 assert!(out.contains("error"), "got: {out}");
1205 assert!(events.iter().any(|l| l.contains(r#""type":"error""#)));
1206 }
1207
1208 #[test]
1209 fn callback_receives_userdata_on_early_error() {
1210 use std::sync::atomic::AtomicUsize;
1211 static RECEIVED_UD: AtomicUsize = AtomicUsize::new(0);
1212 extern "C" fn ud_cb(_line: *const c_char, ud: *mut c_void) {
1213 RECEIVED_UD.store(ud as usize, Ordering::SeqCst);
1214 }
1215 let c = CString::new(r#"{"project_path":"/tmp"}"#).unwrap();
1216 let marker = 0xABCD1234usize as *mut c_void;
1217 let h = aacode_task_start(c.as_ptr(), Some(ud_cb), marker);
1218 let p = aacode_task_wait(h);
1219 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1220 aacode_free_string(p);
1221 aacode_task_free(h);
1222 assert!(s.contains("missing task"), "got: {s}");
1223 assert_eq!(RECEIVED_UD.load(Ordering::SeqCst), 0xABCD1234usize);
1225 }
1226
1227 #[test]
1228 fn start_with_null_callback_still_returns_terminal_result() {
1229 let c = CString::new("not json").unwrap();
1230 let h = aacode_task_start(c.as_ptr(), None, std::ptr::null_mut());
1231 let p = aacode_task_wait(h);
1232 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1233 aacode_free_string(p);
1234 aacode_task_free(h);
1235 assert!(s.contains("bad task_json"), "got: {s}");
1236 }
1237
1238 #[test]
1239 fn null_task_json() {
1240 let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
1241 let ud = &lines as *const _ as *mut c_void;
1242 let h = aacode_task_start(std::ptr::null(), Some(capture_cb), ud);
1243 let result = unsafe {
1244 let p = aacode_task_wait(h);
1245 let s = CStr::from_ptr(p).to_str().unwrap().to_string();
1246 aacode_free_string(p);
1247 aacode_task_free(h);
1248 s
1249 };
1250 assert!(result.contains("null task_json"));
1251 }
1252
1253 #[test]
1254 fn wait_null_handle_is_safe() {
1255 let p = aacode_task_wait(std::ptr::null_mut());
1256 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1257 aacode_free_string(p);
1258 assert!(s.contains("error"));
1259 }
1260
1261 #[test]
1262 fn cancel_null_handle_is_safe() {
1263 aacode_task_cancel(std::ptr::null_mut());
1264 }
1265
1266 #[test]
1267 fn free_null_is_safe() {
1268 aacode_task_free(std::ptr::null_mut());
1269 aacode_free_string(std::ptr::null_mut());
1270 }
1271
1272 #[test]
1273 fn session_guard_rejects_duplicate() {
1274 let g1 = SessionGuard::try_acquire("p::s1".to_string());
1275 assert!(g1.is_some());
1276 let g2 = SessionGuard::try_acquire("p::s1".to_string());
1277 assert!(g2.is_none(), "duplicate session must be rejected");
1278 drop(g1);
1279 let g3 = SessionGuard::try_acquire("p::s1".to_string());
1280 assert!(g3.is_some(), "released session must be reacquirable");
1281 }
1282
1283 #[test]
1287 fn cancel_aborts_task_and_releases_guard() {
1288 let key = format!("/tmp/p::cancel_{}", uuid::Uuid::new_v4().simple());
1289 let (tx, rx) = mpsc::channel::<String>();
1290 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
1291 let cancel = Arc::new(AtomicBool::new(false));
1292 let cancel2 = cancel.clone();
1293 let key2 = key.clone();
1294 let join = TOKIO_RT.spawn(async move {
1295 let _g = SessionGuard::try_acquire(key2).unwrap();
1296 let _ = ready_tx.send(());
1297 futures::future::pending::<()>().await;
1299 });
1300 ready_rx
1301 .recv_timeout(std::time::Duration::from_secs(2))
1302 .expect("task should acquire the guard");
1303 assert!(
1304 SessionGuard::try_acquire(key.clone()).is_none(),
1305 "guard held while running"
1306 );
1307
1308 let handle = Box::into_raw(Box::new(AacodeTask::new(
1309 cancel2,
1310 rx,
1311 tx,
1312 Some(join),
1313 None,
1314 None,
1315 )));
1316 aacode_task_cancel(handle);
1317 std::thread::sleep(std::time::Duration::from_millis(300));
1318 assert!(
1319 SessionGuard::try_acquire(key.clone()).is_some(),
1320 "guard must be released after cancel"
1321 );
1322 aacode_task_free(handle);
1323 }
1324
1325 #[test]
1329 fn cancel_releases_session_synchronously() {
1330 let key = format!("/tmp/p::sync_{}", uuid::Uuid::new_v4().simple());
1331 let (tx, rx) = mpsc::channel::<String>();
1332 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
1333 let cancel = Arc::new(AtomicBool::new(false));
1334 let key2 = key.clone();
1335 let join = TOKIO_RT.spawn(async move {
1336 let _g = SessionGuard::try_acquire(key2).unwrap();
1337 let _ = ready_tx.send(());
1338 let end = std::time::Instant::now() + std::time::Duration::from_millis(1000);
1341 while std::time::Instant::now() < end {
1342 std::hint::spin_loop();
1343 }
1344 });
1345 ready_rx
1346 .recv_timeout(std::time::Duration::from_secs(2))
1347 .expect("task should acquire the guard");
1348 assert!(SessionGuard::try_acquire(key.clone()).is_none());
1349
1350 let handle = Box::into_raw(Box::new(AacodeTask::new(
1351 cancel,
1352 rx,
1353 tx,
1354 Some(join),
1355 Some(key.clone()),
1356 None,
1357 )));
1358 aacode_task_cancel(handle);
1359 assert!(
1361 SessionGuard::try_acquire(key.clone()).is_some(),
1362 "cancel must release the session synchronously"
1363 );
1364 aacode_task_free(handle);
1365 }
1366
1367 #[test]
1368 fn concurrent_tasks_same_session_rejected() {
1369 let _hold = SessionGuard::try_acquire("/tmp/proj::sess_dup".to_string()).unwrap();
1370 let (out, _events) = run_task(
1371 r#"{"task":"do x","project_path":"/tmp/proj","session_id":"sess_dup","model":{"name":"m","api_key":"k"}}"#,
1372 );
1373 assert!(out.contains("already has a task running"), "got: {out}");
1374 }
1375
1376 #[test]
1377 fn list_sessions_ffi() {
1378 let d = std::env::temp_dir().join(format!("ffi_ls_{}", uuid::Uuid::new_v4().simple()));
1379 std::fs::create_dir_all(&d).unwrap();
1380 {
1381 let mut sm = SessionManager::new(&d);
1382 sm.create_session("task", None).unwrap();
1383 }
1384 let c = CString::new(d.to_string_lossy().to_string()).unwrap();
1385 let ptr = aacode_list_sessions(c.as_ptr());
1386 let s = unsafe { CStr::from_ptr(ptr).to_str().unwrap().to_string() };
1387 aacode_free_string(ptr);
1388 assert!(s.contains("\"success\":true"));
1389 }
1390
1391 fn mkdirs(p: &std::path::Path) -> String {
1394 std::fs::create_dir_all(p).unwrap();
1395 p.to_string_lossy().to_string()
1396 }
1397
1398 fn tmp_root(tag: &str) -> std::path::PathBuf {
1399 std::env::temp_dir().join(format!("ffi_root_{tag}_{}", uuid::Uuid::new_v4().simple()))
1400 }
1401
1402 #[test]
1403 fn no_skills_config_keeps_project_jail() {
1404 let base = tmp_root("nocfg");
1405 let sb = mkdirs(&base.join("sb"));
1406 let pp = mkdirs(&base.join("sb").join("proj"));
1407 let (root, rel) = resolve_shell_root(&sb, &pp, None);
1408 assert_eq!(root, pp);
1409 assert!(rel.is_none());
1410 }
1411
1412 #[test]
1413 fn project_inside_sandbox_widens_and_positions_cwd() {
1414 let base = tmp_root("inside");
1415 let sb = mkdirs(&base.join("sb"));
1416 let sk = mkdirs(&base.join("sb").join("skills"));
1417 let pp = mkdirs(&base.join("sb").join("a").join("b"));
1418 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1419 assert_eq!(root, sb);
1420 let rel = rel.expect("must reposition cwd");
1421 assert_eq!(
1422 std::path::PathBuf::from(rel),
1423 std::path::PathBuf::from("a").join("b")
1424 );
1425 }
1426
1427 #[test]
1428 fn project_equal_to_sandbox_user_dir_inside_no_widening() {
1429 let base = tmp_root("equal_in");
1430 let sb = mkdirs(&base.join("sb"));
1431 let sk = mkdirs(&base.join("sb").join("skills"));
1432 let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
1433 assert_eq!(root, sb);
1434 assert!(rel.is_none());
1435 }
1436
1437 #[test]
1438 fn project_equal_to_sandbox_user_dir_outside_widens() {
1439 let base = tmp_root("equal_out");
1440 let sb = mkdirs(&base.join("sb"));
1441 let sk = mkdirs(&base.join("skills"));
1442 let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
1443 let base_c = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
1444 assert_eq!(root, base_c.to_string_lossy());
1445 assert_eq!(rel.as_deref(), Some("sb"));
1446 }
1447
1448 #[test]
1449 fn sibling_prefix_dir_is_not_inside_sandbox() {
1450 let base = tmp_root("sibling");
1451 let sb = mkdirs(&base.join("sb"));
1452 let pp = mkdirs(&base.join("sb2").join("proj"));
1453 let sk = mkdirs(&base.join("sb2").join("proj").join("skills"));
1454 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1455 assert_eq!(root, pp, "sibling project must keep its own jail");
1456 assert!(rel.is_none());
1457 }
1458
1459 #[test]
1460 fn project_outside_sandbox_keeps_own_jail() {
1461 let base = tmp_root("outside");
1462 let sb = mkdirs(&base.join("sb"));
1463 let pp = mkdirs(&base.join("elsewhere").join("proj"));
1464 let sk = mkdirs(&base.join("elsewhere").join("proj").join("skills"));
1465 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1466 assert_eq!(root, pp);
1467 assert!(rel.is_none());
1468 }
1469
1470 #[cfg(unix)]
1471 #[test]
1472 fn symlinked_project_path_still_recognized() {
1473 let base = tmp_root("symlink");
1474 let real_sb = mkdirs(&base.join("real_sb"));
1475 mkdirs(&base.join("real_sb").join("proj"));
1476 let sk = mkdirs(&base.join("real_sb").join("skills"));
1477 let link_sb = base.join("link_sb");
1478 std::os::unix::fs::symlink(&real_sb, &link_sb).unwrap();
1479 let pp_via_link = link_sb.join("proj").to_string_lossy().to_string();
1480 let (root, rel) = resolve_shell_root(&real_sb, &pp_via_link, Some(&sk));
1481 assert_eq!(root, real_sb, "symlinked project must widen to sandbox");
1482 assert_eq!(rel.as_deref(), Some("proj"));
1483 }
1484
1485 fn call_str(f: impl FnOnce() -> *mut c_char) -> String {
1488 let p = f();
1489 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1490 aacode_free_string(p);
1491 s
1492 }
1493
1494 #[test]
1495 fn session_version_is_one() {
1496 assert_eq!(aacode_session_version(), SCHEMA_VERSION);
1497 assert_eq!(aacode_session_version(), 1);
1498 }
1499
1500 #[test]
1501 fn session_lifecycle_roundtrip() {
1502 let proj = mkdirs(&tmp_root("sess_life"));
1503 let pp = CString::new(proj).unwrap();
1504 let sid = CString::new("sess_a").unwrap();
1505
1506 let title = CString::new("My Session").unwrap();
1508 let r = call_str(|| aacode_session_ensure(pp.as_ptr(), sid.as_ptr(), title.as_ptr()));
1509 assert!(r.contains("\"success\":true"), "ensure: {r}");
1510
1511 let msgs = r#"[{"role":"user","content":"hello","timestamp":"1700000001"},{"role":"assistant","content":"hi","timestamp":"1700000002"}]"#;
1513 let mj = CString::new(msgs).unwrap();
1514 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1515 assert!(r.contains("\"success\":true"), "append: {r}");
1516
1517 let new_title = CString::new("Renamed").unwrap();
1519 let r = call_str(|| aacode_session_rename(pp.as_ptr(), sid.as_ptr(), new_title.as_ptr()));
1520 assert!(r.contains("\"success\":true"), "rename: {r}");
1521
1522 let r = call_str(|| aacode_session_list(pp.as_ptr()));
1524 assert!(r.contains("\"success\":true"), "list: {r}");
1525 assert!(r.contains("sess_a"));
1526 assert!(r.contains("Renamed"));
1527
1528 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1530 assert!(r.contains("\"success\":true"), "messages: {r}");
1531 assert!(r.contains("\"total\":2"));
1532 assert!(r.contains("hello"));
1533
1534 let r = call_str(|| aacode_session_touch(pp.as_ptr(), sid.as_ptr()));
1536 assert!(r.contains("\"success\":true"), "touch: {r}");
1537
1538 let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
1540 assert!(r.contains("\"success\":true"), "delete: {r}");
1541 let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
1542 assert!(
1543 r.contains("\"success\":true"),
1544 "delete again (idempotent): {r}"
1545 );
1546 }
1547
1548 #[test]
1549 fn session_append_bad_json_errors() {
1550 let proj = mkdirs(&tmp_root("sess_bad"));
1551 let pp = CString::new(proj).unwrap();
1552 let sid = CString::new("s1").unwrap();
1553 let bad = CString::new("not json").unwrap();
1554 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), bad.as_ptr()));
1555 assert!(
1556 r.contains("\"success\":false"),
1557 "bad msgs_json must error: {r}"
1558 );
1559 assert!(r.contains("bad msgs_json"));
1560 }
1561
1562 #[test]
1563 fn session_messages_pagination() {
1564 let proj = mkdirs(&tmp_root("sess_page"));
1565 let pp = CString::new(proj.clone()).unwrap();
1566 let sid = CString::new("s1").unwrap();
1567 {
1568 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1569 sm.ensure_session("s1", "").unwrap();
1570 for i in 0..5 {
1571 sm.append_session_messages(
1572 "s1",
1573 vec![SessionMessage {
1574 role: "user".to_string(),
1575 content: format!("m{i}"),
1576 timestamp: format!("17000000{i:02}"),
1577 tokens: 1,
1578 tool_calls: None,
1579 tool_call_id: None,
1580 reasoning_content: None,
1581 }],
1582 )
1583 .unwrap();
1584 }
1585 }
1586 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 2));
1588 assert!(r.contains("\"total\":5"), "total: {r}");
1589 assert!(
1590 r.contains("m4") && r.contains("m3") && !r.contains("m0"),
1591 "page0: {r}"
1592 );
1593 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 2, 2));
1595 assert!(
1596 r.contains("m2") && r.contains("m1") && !r.contains("m3"),
1597 "page1: {r}"
1598 );
1599 }
1600
1601 #[test]
1602 fn session_messages_null_args_error() {
1603 let r = call_str(|| aacode_session_messages(std::ptr::null(), std::ptr::null(), 0, 50));
1604 assert!(r.contains("\"success\":false"), "null args: {r}");
1605 }
1606
1607 #[test]
1608 fn session_ffi_rejects_path_traversal_sid() {
1609 let proj = mkdirs(&tmp_root("sess_trav"));
1610 let pp = CString::new(proj).unwrap();
1611 let evil = CString::new("../escape").unwrap();
1612 let r = call_str(|| aacode_session_ensure(pp.as_ptr(), evil.as_ptr(), std::ptr::null()));
1613 assert!(
1614 r.contains("\"success\":false"),
1615 "path traversal must be rejected: {r}"
1616 );
1617 assert!(r.contains("invalid session_id"));
1618 let r = call_str(|| aacode_session_messages(pp.as_ptr(), evil.as_ptr(), 0, 50));
1619 assert!(r.contains("\"success\":false"));
1620 let r = call_str(|| aacode_session_delete(pp.as_ptr(), evil.as_ptr()));
1621 assert!(r.contains("\"success\":false"));
1622 }
1623
1624 #[test]
1625 fn session_messages_limit_zero_and_beyond_total() {
1626 let proj = mkdirs(&tmp_root("sess_l0"));
1627 let pp = CString::new(proj.clone()).unwrap();
1628 let sid = CString::new("s1").unwrap();
1629 {
1630 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1631 sm.ensure_session("s1", "").unwrap();
1632 sm.append_session_messages(
1633 "s1",
1634 vec![SessionMessage {
1635 role: "user".to_string(),
1636 content: "hi".to_string(),
1637 timestamp: "1".to_string(),
1638 tokens: 1,
1639 tool_calls: None,
1640 tool_call_id: None,
1641 reasoning_content: None,
1642 }],
1643 )
1644 .unwrap();
1645 }
1646 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 0));
1648 assert!(r.contains("\"total\":1"), "limit=0 total: {r}");
1649 assert!(
1650 !r.contains("\"hi\""),
1651 "limit=0 must return no messages: {r}"
1652 );
1653 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 5, 10));
1655 assert!(r.contains("\"total\":1"));
1656 assert!(
1657 !r.contains("\"hi\""),
1658 "offset beyond total must be empty: {r}"
1659 );
1660 }
1661
1662 #[test]
1663 fn session_unicode_content_roundtrip() {
1664 let proj = mkdirs(&tmp_root("sess_uni"));
1665 let pp = CString::new(proj).unwrap();
1666 let sid = CString::new("s1").unwrap();
1667 let msgs = r#"[{"role":"user","content":"你好,世界 🌍","timestamp":"1700000001"}]"#;
1668 let mj = CString::new(msgs).unwrap();
1669 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1670 assert!(r.contains("\"success\":true"), "append unicode: {r}");
1671 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1672 assert!(r.contains("你好,世界"), "unicode content preserved: {r}");
1673 }
1674
1675 #[test]
1676 fn session_append_ignores_segments_field() {
1677 let proj = mkdirs(&tmp_root("sess_segs"));
1680 let pp = CString::new(proj).unwrap();
1681 let sid = CString::new("s1").unwrap();
1682 let msgs = r#"[
1683 {"role":"assistant","content":"ok","timestamp":"1700000001",
1684 "segments":[
1685 {"type":"thinking","content":"think","created_at":"1700000001.001"},
1686 {"type":"action","name":"run_shell","content":"{}","created_at":"1700000001.002"}
1687 ]}
1688 ]"#;
1689 let mj = CString::new(msgs).unwrap();
1690 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1691 assert!(r.contains("\"success\":true"), "append: {r}");
1692
1693 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1694 assert!(r.contains("\"ok\""), "content preserved: {r}");
1695 assert!(
1696 !r.contains("segments"),
1697 "legacy segments must be dropped, not persisted: {r}"
1698 );
1699 }
1700
1701 #[test]
1702 fn session_append_without_segments_still_works() {
1703 let proj = mkdirs(&tmp_root("sess_noseg"));
1704 let pp = CString::new(proj).unwrap();
1705 let sid = CString::new("s1").unwrap();
1706 let msgs = r#"[{"role":"user","content":"hi","timestamp":"1700000001"}]"#;
1707 let mj = CString::new(msgs).unwrap();
1708 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1709 assert!(
1710 r.contains("\"success\":true"),
1711 "append without segments: {r}"
1712 );
1713 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1714 assert!(
1715 r.contains("\"hi\""),
1716 "message without segments still read: {r}"
1717 );
1718 assert!(
1719 !r.contains("segments"),
1720 "segments are never emitted in the wire: {r}"
1721 );
1722 }
1723
1724 #[test]
1725 fn session_list_empty_project() {
1726 let proj = mkdirs(&tmp_root("sess_empty"));
1727 let pp = CString::new(proj).unwrap();
1728 let r = call_str(|| aacode_session_list(pp.as_ptr()));
1729 assert!(r.contains("\"success\":true"));
1730 assert!(r.contains("\"sessions\":[]"), "empty project: {r}");
1731 }
1732}
1733
1734#[cfg(test)]
1735mod surface_ffi_tests {
1736 use super::*;
1737
1738 #[test]
1739 fn register_surface_ops_rejects_null() {
1740 assert_eq!(
1742 unsafe { aacode_browser_register_surface_ops(std::ptr::null()) },
1743 -1
1744 );
1745 }
1746}
1747
1748#[cfg(all(test, feature = "pty", unix))]
1749mod pty_ffi_tests {
1750 use super::*;
1751 use base64::Engine as _;
1752
1753 fn take(ptr: *mut c_char) -> serde_json::Value {
1754 assert!(!ptr.is_null());
1755 let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string();
1756 aacode_free_string(ptr);
1757 serde_json::from_str(&s).unwrap()
1758 }
1759
1760 #[test]
1761 fn terminal_ffi_open_write_read_list_close() {
1762 let shell = CString::new("/bin/sh").unwrap();
1763 let cwd = CString::new(".").unwrap();
1764 let open = take(aacode_terminal_open(shell.as_ptr(), cwd.as_ptr(), 80, 24));
1765 assert_eq!(open["ok"], true, "{open:?}");
1766 let id = open["id"].as_str().unwrap().to_string();
1767 let idc = CString::new(id.clone()).unwrap();
1768
1769 let cmd = b"printf 'FFI_OK\\n'\n";
1770 let w = take(aacode_terminal_write(idc.as_ptr(), cmd.as_ptr(), cmd.len()));
1771 assert_eq!(w["ok"], true, "{w:?}");
1772
1773 let mut cursor = 0u64;
1774 let mut all = String::new();
1775 for _ in 0..200 {
1776 let r = take(aacode_terminal_read(idc.as_ptr(), cursor, 4096));
1777 if !r["ok"].as_bool().unwrap_or(false) {
1778 break;
1779 }
1780 cursor = r["cursor"].as_u64().unwrap_or(cursor);
1781 if let Some(b64) = r["data_b64"].as_str() {
1782 let bytes = base64::engine::general_purpose::STANDARD
1783 .decode(b64)
1784 .unwrap();
1785 all.push_str(&String::from_utf8_lossy(&bytes));
1786 }
1787 if all.contains("FFI_OK") {
1788 break;
1789 }
1790 std::thread::sleep(std::time::Duration::from_millis(20));
1791 }
1792 assert!(all.contains("FFI_OK"), "output: {all:?}");
1793
1794 let list = take(aacode_terminal_list());
1795 assert!(list["sessions"]
1796 .as_array()
1797 .unwrap()
1798 .iter()
1799 .any(|s| s["id"].as_str() == Some(id.as_str())));
1800
1801 let resized = take(aacode_terminal_resize(idc.as_ptr(), 100, 40));
1802 assert_eq!(resized["ok"], true);
1803 let closed = take(aacode_terminal_close(idc.as_ptr()));
1804 assert_eq!(closed["ok"], true);
1805 }
1806}