use aacode_rs::config::AgentConfig;
use aacode_rs::llm::types::ChatMessage;
use aacode_rs::runtime::AgentRuntime;
use aacode_rs::session::SessionManager;
use aacode_rs::stream::CollectingSink;
use std::sync::atomic::AtomicBool;
use std::thread;
fn tmp_project() -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"aacode_cs_{}_{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&d).unwrap();
d
}
fn sse_content(text: &str, finish: &str) -> String {
format!(
"data: {{\"choices\":[{{\"delta\":{{\"content\":{text:?}}}}}]}}\n\n\
data: {{\"choices\":[{{\"delta\":{{}},\"finish_reason\":{finish:?}}}]}}\n\n\
data: [DONE]\n\n"
)
}
struct MockLlm {
addr: String,
_handle: thread::JoinHandle<()>,
}
impl MockLlm {
fn start(responses: Vec<String>) -> Self {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let addr = format!("http://{}", server.server_addr());
let handle = thread::spawn(move || {
let mut queue = responses.into_iter();
for mut request in server.incoming_requests() {
let mut buf = String::new();
let _ = request.as_reader().read_to_string(&mut buf);
let body = queue
.next()
.unwrap_or_else(|| "data: [DONE]\n\n".to_string());
let header =
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/event-stream"[..])
.unwrap();
let response = tiny_http::Response::from_string(body).with_header(header);
let _ = request.respond(response);
if queue.len() == 0 {
break;
}
}
});
MockLlm {
addr,
_handle: handle,
}
}
}
fn config_for(addr: &str) -> AgentConfig {
let mut cfg = AgentConfig::default();
cfg.model.name = "mock-model".into();
cfg.model.api_key = Some("sk-test".into());
cfg.model.base_url = Some(format!("{addr}/v1"));
cfg.model.gateway = aacode_rs::config::Gateway::Openai;
cfg
}
fn extract_session_id(sink: &CollectingSink) -> String {
let lines = sink.lines();
let sc: Vec<_> = lines
.iter()
.filter(|l| l.contains(r#""type":"session_created""#))
.collect();
assert!(!sc.is_empty(), "session_created event missing");
let line = sc[0];
let start = line.find(r#""session_id":""#).unwrap() + r#""session_id":""#.len();
let end = line[start..].find('"').unwrap();
line[start..start + end].to_string()
}
#[tokio::test]
async fn execute_produces_user_and_assistant_only() {
let mock = MockLlm::start(vec![sse_content("Task completed.", "stop")]);
let cfg = config_for(&mock.addr);
let proj = tmp_project();
let rt = AgentRuntime::init(cfg, proj.clone()).unwrap();
let sink = CollectingSink::new(false);
let cancel = AtomicBool::new(false);
let res = rt
.run_task("analyze the project", None, &sink, &cancel)
.await
.unwrap();
assert_eq!(format!("{:?}", res.status), "Completed");
let sid = extract_session_id(&sink);
let sm = SessionManager::new(&proj);
let msgs = sm.read_session_messages(&sid);
assert!(!msgs.is_empty());
assert!(msgs
.iter()
.any(|m| m.role == "user" && m.content.contains("analyze the project")));
assert!(msgs.iter().any(|m| m.role == "assistant"));
let system_count = msgs.iter().filter(|m| m.role == "system").count();
assert_eq!(
system_count, 0,
"no system messages should be persisted (found {system_count})"
);
}
#[tokio::test]
async fn consecutive_tasks_preserve_history() {
let mock = MockLlm::start(vec![
sse_content("First task done.", "stop"),
sse_content("Second task done.", "stop"),
]);
let cfg = config_for(&mock.addr);
let proj = tmp_project();
let rt = AgentRuntime::init(cfg, proj.clone()).unwrap();
let cancel = AtomicBool::new(false);
let sink1 = CollectingSink::new(false);
let res1 = rt
.run_task("task one", None, &sink1, &cancel)
.await
.unwrap();
assert_eq!(format!("{:?}", res1.status), "Completed");
let sid = extract_session_id(&sink1);
let sink2 = CollectingSink::new(false);
let res2 = rt
.run_task("task two", Some(&sid), &sink2, &cancel)
.await
.unwrap();
assert_eq!(format!("{:?}", res2.status), "Completed");
let sm = SessionManager::new(&proj);
let msgs = sm.read_session_messages(&sid);
let user_count = msgs.iter().filter(|m| m.role == "user").count();
assert!(
user_count >= 2,
"session should have >=2 user messages, got {user_count}"
);
let assistant_count = msgs.iter().filter(|m| m.role == "assistant").count();
assert!(
assistant_count >= 2,
"session should have >=2 assistant messages, got {assistant_count}"
);
let system_count = msgs.iter().filter(|m| m.role == "system").count();
assert_eq!(
system_count, 0,
"no system messages should be persisted (found {system_count})"
);
let last_task1_user = msgs
.iter()
.position(|m| m.role == "user" && m.content.contains("task one"))
.unwrap();
let last_task2_user = msgs
.iter()
.rposition(|m| m.role == "user" && m.content.contains("task two"))
.unwrap();
assert!(
last_task1_user < last_task2_user,
"task 1 messages must precede task 2 messages"
);
}
#[test]
fn compact_view_prefix_stable_within_call() {
use aacode_rs::agent::compact::{build_compact_view_cached, CompactCache};
use aacode_rs::config::ContextConfig;
use aacode_rs::llm::types::ToolCall;
fn convo(rounds: usize) -> Vec<ChatMessage> {
let mut v = vec![ChatMessage::system("STATIC_SYSTEM_PROMPT")];
for i in 0..rounds {
v.push(ChatMessage::user(format!("ask {i} {}", "x".repeat(200))));
v.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall {
id: format!("c{i}"),
name: "run_shell".into(),
arguments: "{}".into(),
}],
));
v.push(ChatMessage::tool_result(format!("c{i}"), "y".repeat(200)));
}
v
}
fn view_prefix(view: &[ChatMessage]) -> Vec<String> {
view.iter()
.map(|m| {
format!(
"{}|{}|{:?}",
m.role,
m.content.chars().take(50).collect::<String>(),
m.tool_calls.as_ref().map(|t| t.len())
)
})
.collect()
}
let cfg = ContextConfig {
compact_trigger_tokens: 10,
protect_first_rounds: 1,
keep_last_rounds: 2,
protect_last_user_rounds: 1,
..Default::default()
};
let mut cache: Option<CompactCache> = None;
let mut msgs = convo(12);
let (v1, c1, _) = build_compact_view_cached(&msgs, &cfg, &mut cache);
let v1 = v1.into_owned();
assert!(c1, "should trigger compaction");
assert!(cache.is_some(), "cache must be populated");
msgs.push(ChatMessage::user("follow-up question"));
let (v2, _, _) = build_compact_view_cached(&msgs, &cfg, &mut cache);
let v2 = v2.into_owned();
assert!(v2.len() > v1.len(), "view should grow");
let p1 = view_prefix(&v1);
let p2 = view_prefix(&v2);
assert_eq!(
p1,
p2[..p1.len()],
"compacted view prefix must be stable across iterations"
);
msgs.push(ChatMessage::user("another follow-up"));
let (v3, _, _) = build_compact_view_cached(&msgs, &cfg, &mut cache);
let v3 = v3.into_owned();
let p3 = view_prefix(&v3);
assert_eq!(
p2,
p3[..p2.len()],
"second append must also extend prefix stably"
);
}
#[test]
fn system_prompt_includes_init_md_not_analysis() {
use aacode_rs::agent::MainAgent;
use aacode_rs::tools::backend::NativeShell;
use aacode_rs::tools::ShellBackend;
use std::sync::Arc;
let proj = tmp_project();
let mut cfg = AgentConfig::default();
cfg.model.api_key = Some("sk-test".into());
let backend: Arc<dyn ShellBackend> = Arc::new(NativeShell::new());
let agent = MainAgent::new(cfg, proj.clone(), backend);
let _reg = agent.build_registry();
let ctx = aacode_rs::context::ContextManager::new(&proj);
let init = ctx.load_init_instructions();
assert!(!init.is_empty(), "init.md should be available");
let analysis = ctx.analyze_project_structure();
assert!(
!analysis.is_empty(),
"analyze_project_structure should still work"
);
}