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(test)]
964mod tests {
965 use super::*;
966
967 extern "C" fn capture_cb(line: *const c_char, userdata: *mut c_void) {
968 if line.is_null() {
969 return;
970 }
971 let l = unsafe { CStr::from_ptr(line).to_str().unwrap_or("").to_string() };
972 let sink = userdata as *mut Mutex<Vec<String>>;
973 if !sink.is_null() {
974 unsafe {
975 if let Ok(mut v) = (*sink).lock() {
976 v.push(l);
977 }
978 }
979 }
980 }
981
982 fn run_task(json: &str) -> (String, Vec<String>) {
983 let c = CString::new(json).unwrap();
984 let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
985 let ud = &lines as *const _ as *mut c_void;
986 let h = aacode_task_start(c.as_ptr(), Some(capture_cb), ud);
987 let result = unsafe {
988 let p = aacode_task_wait(h);
989 let s = CStr::from_ptr(p).to_str().unwrap().to_string();
990 aacode_free_string(p);
991 aacode_task_free(h);
992 s
993 };
994 let captured = lines.lock().unwrap().clone();
995 (result, captured)
996 }
997
998 #[test]
999 fn run_task_bad_json() {
1000 let (out, events) = run_task("not json");
1001 assert!(out.contains("bad task_json"), "got: {out}");
1002 assert!(
1003 events
1004 .iter()
1005 .any(|l| l.contains(r#""type":"error""#) && l.contains("bad task_json")),
1006 "error event must be emitted: {events:?}"
1007 );
1008 }
1009
1010 #[test]
1011 fn run_task_missing_task() {
1012 let (out, events) = run_task(r#"{"project_path":"/tmp"}"#);
1013 assert!(out.contains("error"), "got: {out}");
1014 assert!(events.iter().any(|l| l.contains(r#""type":"error""#)));
1015 }
1016
1017 #[test]
1018 fn callback_receives_userdata_on_early_error() {
1019 use std::sync::atomic::AtomicUsize;
1020 static RECEIVED_UD: AtomicUsize = AtomicUsize::new(0);
1021 extern "C" fn ud_cb(_line: *const c_char, ud: *mut c_void) {
1022 RECEIVED_UD.store(ud as usize, Ordering::SeqCst);
1023 }
1024 let c = CString::new(r#"{"project_path":"/tmp"}"#).unwrap();
1025 let marker = 0xABCD1234usize as *mut c_void;
1026 let h = aacode_task_start(c.as_ptr(), Some(ud_cb), marker);
1027 let p = aacode_task_wait(h);
1028 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1029 aacode_free_string(p);
1030 aacode_task_free(h);
1031 assert!(s.contains("missing task"), "got: {s}");
1032 assert_eq!(RECEIVED_UD.load(Ordering::SeqCst), 0xABCD1234usize);
1034 }
1035
1036 #[test]
1037 fn start_with_null_callback_still_returns_terminal_result() {
1038 let c = CString::new("not json").unwrap();
1039 let h = aacode_task_start(c.as_ptr(), None, std::ptr::null_mut());
1040 let p = aacode_task_wait(h);
1041 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1042 aacode_free_string(p);
1043 aacode_task_free(h);
1044 assert!(s.contains("bad task_json"), "got: {s}");
1045 }
1046
1047 #[test]
1048 fn null_task_json() {
1049 let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
1050 let ud = &lines as *const _ as *mut c_void;
1051 let h = aacode_task_start(std::ptr::null(), Some(capture_cb), ud);
1052 let result = unsafe {
1053 let p = aacode_task_wait(h);
1054 let s = CStr::from_ptr(p).to_str().unwrap().to_string();
1055 aacode_free_string(p);
1056 aacode_task_free(h);
1057 s
1058 };
1059 assert!(result.contains("null task_json"));
1060 }
1061
1062 #[test]
1063 fn wait_null_handle_is_safe() {
1064 let p = aacode_task_wait(std::ptr::null_mut());
1065 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1066 aacode_free_string(p);
1067 assert!(s.contains("error"));
1068 }
1069
1070 #[test]
1071 fn cancel_null_handle_is_safe() {
1072 aacode_task_cancel(std::ptr::null_mut());
1073 }
1074
1075 #[test]
1076 fn free_null_is_safe() {
1077 aacode_task_free(std::ptr::null_mut());
1078 aacode_free_string(std::ptr::null_mut());
1079 }
1080
1081 #[test]
1082 fn session_guard_rejects_duplicate() {
1083 let g1 = SessionGuard::try_acquire("p::s1".to_string());
1084 assert!(g1.is_some());
1085 let g2 = SessionGuard::try_acquire("p::s1".to_string());
1086 assert!(g2.is_none(), "duplicate session must be rejected");
1087 drop(g1);
1088 let g3 = SessionGuard::try_acquire("p::s1".to_string());
1089 assert!(g3.is_some(), "released session must be reacquirable");
1090 }
1091
1092 #[test]
1096 fn cancel_aborts_task_and_releases_guard() {
1097 let key = format!("/tmp/p::cancel_{}", uuid::Uuid::new_v4().simple());
1098 let (tx, rx) = mpsc::channel::<String>();
1099 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
1100 let cancel = Arc::new(AtomicBool::new(false));
1101 let cancel2 = cancel.clone();
1102 let key2 = key.clone();
1103 let join = TOKIO_RT.spawn(async move {
1104 let _g = SessionGuard::try_acquire(key2).unwrap();
1105 let _ = ready_tx.send(());
1106 futures::future::pending::<()>().await;
1108 });
1109 ready_rx
1110 .recv_timeout(std::time::Duration::from_secs(2))
1111 .expect("task should acquire the guard");
1112 assert!(
1113 SessionGuard::try_acquire(key.clone()).is_none(),
1114 "guard held while running"
1115 );
1116
1117 let handle = Box::into_raw(Box::new(AacodeTask::new(
1118 cancel2,
1119 rx,
1120 tx,
1121 Some(join),
1122 None,
1123 None,
1124 )));
1125 aacode_task_cancel(handle);
1126 std::thread::sleep(std::time::Duration::from_millis(300));
1127 assert!(
1128 SessionGuard::try_acquire(key.clone()).is_some(),
1129 "guard must be released after cancel"
1130 );
1131 aacode_task_free(handle);
1132 }
1133
1134 #[test]
1138 fn cancel_releases_session_synchronously() {
1139 let key = format!("/tmp/p::sync_{}", uuid::Uuid::new_v4().simple());
1140 let (tx, rx) = mpsc::channel::<String>();
1141 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
1142 let cancel = Arc::new(AtomicBool::new(false));
1143 let key2 = key.clone();
1144 let join = TOKIO_RT.spawn(async move {
1145 let _g = SessionGuard::try_acquire(key2).unwrap();
1146 let _ = ready_tx.send(());
1147 let end = std::time::Instant::now() + std::time::Duration::from_millis(1000);
1150 while std::time::Instant::now() < end {
1151 std::hint::spin_loop();
1152 }
1153 });
1154 ready_rx
1155 .recv_timeout(std::time::Duration::from_secs(2))
1156 .expect("task should acquire the guard");
1157 assert!(SessionGuard::try_acquire(key.clone()).is_none());
1158
1159 let handle = Box::into_raw(Box::new(AacodeTask::new(
1160 cancel,
1161 rx,
1162 tx,
1163 Some(join),
1164 Some(key.clone()),
1165 None,
1166 )));
1167 aacode_task_cancel(handle);
1168 assert!(
1170 SessionGuard::try_acquire(key.clone()).is_some(),
1171 "cancel must release the session synchronously"
1172 );
1173 aacode_task_free(handle);
1174 }
1175
1176 #[test]
1177 fn concurrent_tasks_same_session_rejected() {
1178 let _hold = SessionGuard::try_acquire("/tmp/proj::sess_dup".to_string()).unwrap();
1179 let (out, _events) = run_task(
1180 r#"{"task":"do x","project_path":"/tmp/proj","session_id":"sess_dup","model":{"name":"m","api_key":"k"}}"#,
1181 );
1182 assert!(out.contains("already has a task running"), "got: {out}");
1183 }
1184
1185 #[test]
1186 fn list_sessions_ffi() {
1187 let d = std::env::temp_dir().join(format!("ffi_ls_{}", uuid::Uuid::new_v4().simple()));
1188 std::fs::create_dir_all(&d).unwrap();
1189 {
1190 let mut sm = SessionManager::new(&d);
1191 sm.create_session("task", None).unwrap();
1192 }
1193 let c = CString::new(d.to_string_lossy().to_string()).unwrap();
1194 let ptr = aacode_list_sessions(c.as_ptr());
1195 let s = unsafe { CStr::from_ptr(ptr).to_str().unwrap().to_string() };
1196 aacode_free_string(ptr);
1197 assert!(s.contains("\"success\":true"));
1198 }
1199
1200 fn mkdirs(p: &std::path::Path) -> String {
1203 std::fs::create_dir_all(p).unwrap();
1204 p.to_string_lossy().to_string()
1205 }
1206
1207 fn tmp_root(tag: &str) -> std::path::PathBuf {
1208 std::env::temp_dir().join(format!("ffi_root_{tag}_{}", uuid::Uuid::new_v4().simple()))
1209 }
1210
1211 #[test]
1212 fn no_skills_config_keeps_project_jail() {
1213 let base = tmp_root("nocfg");
1214 let sb = mkdirs(&base.join("sb"));
1215 let pp = mkdirs(&base.join("sb").join("proj"));
1216 let (root, rel) = resolve_shell_root(&sb, &pp, None);
1217 assert_eq!(root, pp);
1218 assert!(rel.is_none());
1219 }
1220
1221 #[test]
1222 fn project_inside_sandbox_widens_and_positions_cwd() {
1223 let base = tmp_root("inside");
1224 let sb = mkdirs(&base.join("sb"));
1225 let sk = mkdirs(&base.join("sb").join("skills"));
1226 let pp = mkdirs(&base.join("sb").join("a").join("b"));
1227 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1228 assert_eq!(root, sb);
1229 let rel = rel.expect("must reposition cwd");
1230 assert_eq!(
1231 std::path::PathBuf::from(rel),
1232 std::path::PathBuf::from("a").join("b")
1233 );
1234 }
1235
1236 #[test]
1237 fn project_equal_to_sandbox_user_dir_inside_no_widening() {
1238 let base = tmp_root("equal_in");
1239 let sb = mkdirs(&base.join("sb"));
1240 let sk = mkdirs(&base.join("sb").join("skills"));
1241 let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
1242 assert_eq!(root, sb);
1243 assert!(rel.is_none());
1244 }
1245
1246 #[test]
1247 fn project_equal_to_sandbox_user_dir_outside_widens() {
1248 let base = tmp_root("equal_out");
1249 let sb = mkdirs(&base.join("sb"));
1250 let sk = mkdirs(&base.join("skills"));
1251 let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
1252 let base_c = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
1253 assert_eq!(root, base_c.to_string_lossy());
1254 assert_eq!(rel.as_deref(), Some("sb"));
1255 }
1256
1257 #[test]
1258 fn sibling_prefix_dir_is_not_inside_sandbox() {
1259 let base = tmp_root("sibling");
1260 let sb = mkdirs(&base.join("sb"));
1261 let pp = mkdirs(&base.join("sb2").join("proj"));
1262 let sk = mkdirs(&base.join("sb2").join("proj").join("skills"));
1263 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1264 assert_eq!(root, pp, "sibling project must keep its own jail");
1265 assert!(rel.is_none());
1266 }
1267
1268 #[test]
1269 fn project_outside_sandbox_keeps_own_jail() {
1270 let base = tmp_root("outside");
1271 let sb = mkdirs(&base.join("sb"));
1272 let pp = mkdirs(&base.join("elsewhere").join("proj"));
1273 let sk = mkdirs(&base.join("elsewhere").join("proj").join("skills"));
1274 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
1275 assert_eq!(root, pp);
1276 assert!(rel.is_none());
1277 }
1278
1279 #[cfg(unix)]
1280 #[test]
1281 fn symlinked_project_path_still_recognized() {
1282 let base = tmp_root("symlink");
1283 let real_sb = mkdirs(&base.join("real_sb"));
1284 mkdirs(&base.join("real_sb").join("proj"));
1285 let sk = mkdirs(&base.join("real_sb").join("skills"));
1286 let link_sb = base.join("link_sb");
1287 std::os::unix::fs::symlink(&real_sb, &link_sb).unwrap();
1288 let pp_via_link = link_sb.join("proj").to_string_lossy().to_string();
1289 let (root, rel) = resolve_shell_root(&real_sb, &pp_via_link, Some(&sk));
1290 assert_eq!(root, real_sb, "symlinked project must widen to sandbox");
1291 assert_eq!(rel.as_deref(), Some("proj"));
1292 }
1293
1294 fn call_str(f: impl FnOnce() -> *mut c_char) -> String {
1297 let p = f();
1298 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
1299 aacode_free_string(p);
1300 s
1301 }
1302
1303 #[test]
1304 fn session_version_is_one() {
1305 assert_eq!(aacode_session_version(), SCHEMA_VERSION);
1306 assert_eq!(aacode_session_version(), 1);
1307 }
1308
1309 #[test]
1310 fn session_lifecycle_roundtrip() {
1311 let proj = mkdirs(&tmp_root("sess_life"));
1312 let pp = CString::new(proj).unwrap();
1313 let sid = CString::new("sess_a").unwrap();
1314
1315 let title = CString::new("My Session").unwrap();
1317 let r = call_str(|| aacode_session_ensure(pp.as_ptr(), sid.as_ptr(), title.as_ptr()));
1318 assert!(r.contains("\"success\":true"), "ensure: {r}");
1319
1320 let msgs = r#"[{"role":"user","content":"hello","timestamp":"1700000001"},{"role":"assistant","content":"hi","timestamp":"1700000002"}]"#;
1322 let mj = CString::new(msgs).unwrap();
1323 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1324 assert!(r.contains("\"success\":true"), "append: {r}");
1325
1326 let new_title = CString::new("Renamed").unwrap();
1328 let r = call_str(|| aacode_session_rename(pp.as_ptr(), sid.as_ptr(), new_title.as_ptr()));
1329 assert!(r.contains("\"success\":true"), "rename: {r}");
1330
1331 let r = call_str(|| aacode_session_list(pp.as_ptr()));
1333 assert!(r.contains("\"success\":true"), "list: {r}");
1334 assert!(r.contains("sess_a"));
1335 assert!(r.contains("Renamed"));
1336
1337 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1339 assert!(r.contains("\"success\":true"), "messages: {r}");
1340 assert!(r.contains("\"total\":2"));
1341 assert!(r.contains("hello"));
1342
1343 let r = call_str(|| aacode_session_touch(pp.as_ptr(), sid.as_ptr()));
1345 assert!(r.contains("\"success\":true"), "touch: {r}");
1346
1347 let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
1349 assert!(r.contains("\"success\":true"), "delete: {r}");
1350 let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
1351 assert!(
1352 r.contains("\"success\":true"),
1353 "delete again (idempotent): {r}"
1354 );
1355 }
1356
1357 #[test]
1358 fn session_append_bad_json_errors() {
1359 let proj = mkdirs(&tmp_root("sess_bad"));
1360 let pp = CString::new(proj).unwrap();
1361 let sid = CString::new("s1").unwrap();
1362 let bad = CString::new("not json").unwrap();
1363 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), bad.as_ptr()));
1364 assert!(
1365 r.contains("\"success\":false"),
1366 "bad msgs_json must error: {r}"
1367 );
1368 assert!(r.contains("bad msgs_json"));
1369 }
1370
1371 #[test]
1372 fn session_messages_pagination() {
1373 let proj = mkdirs(&tmp_root("sess_page"));
1374 let pp = CString::new(proj.clone()).unwrap();
1375 let sid = CString::new("s1").unwrap();
1376 {
1377 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1378 sm.ensure_session("s1", "").unwrap();
1379 for i in 0..5 {
1380 sm.append_session_messages(
1381 "s1",
1382 vec![SessionMessage {
1383 role: "user".to_string(),
1384 content: format!("m{i}"),
1385 timestamp: format!("17000000{i:02}"),
1386 tokens: 1,
1387 tool_calls: None,
1388 tool_call_id: None,
1389 reasoning_content: None,
1390 }],
1391 )
1392 .unwrap();
1393 }
1394 }
1395 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 2));
1397 assert!(r.contains("\"total\":5"), "total: {r}");
1398 assert!(
1399 r.contains("m4") && r.contains("m3") && !r.contains("m0"),
1400 "page0: {r}"
1401 );
1402 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 2, 2));
1404 assert!(
1405 r.contains("m2") && r.contains("m1") && !r.contains("m3"),
1406 "page1: {r}"
1407 );
1408 }
1409
1410 #[test]
1411 fn session_messages_null_args_error() {
1412 let r = call_str(|| aacode_session_messages(std::ptr::null(), std::ptr::null(), 0, 50));
1413 assert!(r.contains("\"success\":false"), "null args: {r}");
1414 }
1415
1416 #[test]
1417 fn session_ffi_rejects_path_traversal_sid() {
1418 let proj = mkdirs(&tmp_root("sess_trav"));
1419 let pp = CString::new(proj).unwrap();
1420 let evil = CString::new("../escape").unwrap();
1421 let r = call_str(|| aacode_session_ensure(pp.as_ptr(), evil.as_ptr(), std::ptr::null()));
1422 assert!(
1423 r.contains("\"success\":false"),
1424 "path traversal must be rejected: {r}"
1425 );
1426 assert!(r.contains("invalid session_id"));
1427 let r = call_str(|| aacode_session_messages(pp.as_ptr(), evil.as_ptr(), 0, 50));
1428 assert!(r.contains("\"success\":false"));
1429 let r = call_str(|| aacode_session_delete(pp.as_ptr(), evil.as_ptr()));
1430 assert!(r.contains("\"success\":false"));
1431 }
1432
1433 #[test]
1434 fn session_messages_limit_zero_and_beyond_total() {
1435 let proj = mkdirs(&tmp_root("sess_l0"));
1436 let pp = CString::new(proj.clone()).unwrap();
1437 let sid = CString::new("s1").unwrap();
1438 {
1439 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1440 sm.ensure_session("s1", "").unwrap();
1441 sm.append_session_messages(
1442 "s1",
1443 vec![SessionMessage {
1444 role: "user".to_string(),
1445 content: "hi".to_string(),
1446 timestamp: "1".to_string(),
1447 tokens: 1,
1448 tool_calls: None,
1449 tool_call_id: None,
1450 reasoning_content: None,
1451 }],
1452 )
1453 .unwrap();
1454 }
1455 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 0));
1457 assert!(r.contains("\"total\":1"), "limit=0 total: {r}");
1458 assert!(
1459 !r.contains("\"hi\""),
1460 "limit=0 must return no messages: {r}"
1461 );
1462 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 5, 10));
1464 assert!(r.contains("\"total\":1"));
1465 assert!(
1466 !r.contains("\"hi\""),
1467 "offset beyond total must be empty: {r}"
1468 );
1469 }
1470
1471 #[test]
1472 fn session_unicode_content_roundtrip() {
1473 let proj = mkdirs(&tmp_root("sess_uni"));
1474 let pp = CString::new(proj).unwrap();
1475 let sid = CString::new("s1").unwrap();
1476 let msgs = r#"[{"role":"user","content":"你好,世界 🌍","timestamp":"1700000001"}]"#;
1477 let mj = CString::new(msgs).unwrap();
1478 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1479 assert!(r.contains("\"success\":true"), "append unicode: {r}");
1480 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1481 assert!(r.contains("你好,世界"), "unicode content preserved: {r}");
1482 }
1483
1484 #[test]
1485 fn session_append_ignores_segments_field() {
1486 let proj = mkdirs(&tmp_root("sess_segs"));
1489 let pp = CString::new(proj).unwrap();
1490 let sid = CString::new("s1").unwrap();
1491 let msgs = r#"[
1492 {"role":"assistant","content":"ok","timestamp":"1700000001",
1493 "segments":[
1494 {"type":"thinking","content":"think","created_at":"1700000001.001"},
1495 {"type":"action","name":"run_shell","content":"{}","created_at":"1700000001.002"}
1496 ]}
1497 ]"#;
1498 let mj = CString::new(msgs).unwrap();
1499 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1500 assert!(r.contains("\"success\":true"), "append: {r}");
1501
1502 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1503 assert!(r.contains("\"ok\""), "content preserved: {r}");
1504 assert!(
1505 !r.contains("segments"),
1506 "legacy segments must be dropped, not persisted: {r}"
1507 );
1508 }
1509
1510 #[test]
1511 fn session_append_without_segments_still_works() {
1512 let proj = mkdirs(&tmp_root("sess_noseg"));
1513 let pp = CString::new(proj).unwrap();
1514 let sid = CString::new("s1").unwrap();
1515 let msgs = r#"[{"role":"user","content":"hi","timestamp":"1700000001"}]"#;
1516 let mj = CString::new(msgs).unwrap();
1517 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1518 assert!(
1519 r.contains("\"success\":true"),
1520 "append without segments: {r}"
1521 );
1522 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1523 assert!(
1524 r.contains("\"hi\""),
1525 "message without segments still read: {r}"
1526 );
1527 assert!(
1528 !r.contains("segments"),
1529 "segments are never emitted in the wire: {r}"
1530 );
1531 }
1532
1533 #[test]
1534 fn session_list_empty_project() {
1535 let proj = mkdirs(&tmp_root("sess_empty"));
1536 let pp = CString::new(proj).unwrap();
1537 let r = call_str(|| aacode_session_list(pp.as_ptr()));
1538 assert!(r.contains("\"success\":true"));
1539 assert!(r.contains("\"sessions\":[]"), "empty project: {r}");
1540 }
1541}
1542
1543#[cfg(test)]
1544mod surface_ffi_tests {
1545 use super::*;
1546
1547 #[test]
1548 fn register_surface_ops_rejects_null() {
1549 assert_eq!(
1551 unsafe { aacode_browser_register_surface_ops(std::ptr::null()) },
1552 -1
1553 );
1554 }
1555}