use std::sync::Arc;
use std::time::Duration;
use atomcode_coding::{assemble, prepare, CodingAgentConfig, PrepareOptions, SessionMode};
use atomcode_kernel::event::{AgentCommand, AgentEvent};
use atomcode_kernel::message::Role;
use atomcode_kernel::stream::StreamEvent;
use atomcode_kernel::testkit::RecordingProvider;
use atomcode_kernel::tool::ToolCall;
#[ctor::ctor]
fn _isolate_atomcode_home() {
atomcode_kernel::test_support::isolate_home();
}
fn cfg(working_dir: &std::path::Path) -> CodingAgentConfig {
let mut c = CodingAgentConfig::new("k", "http://unused", "test-model", working_dir);
c.stream_timeout = Duration::from_secs(5);
c.request_timeout = Some(Duration::from_secs(5));
c
}
fn text_turn(text: &str) -> Vec<StreamEvent> {
vec![
StreamEvent::TextDelta(text.into()),
StreamEvent::Done { truncated: false },
]
}
async fn drive(
handle: &mut atomcode_kernel::agent::AgentHandle,
text: &str,
decision: Option<&str>,
) -> (Vec<AgentEvent>, usize) {
handle
.commands
.send(AgentCommand::SendMessage {
text: text.into(),
images: vec![],
})
.unwrap();
let mut events = Vec::new();
let mut requests = 0;
while let Some(ev) = handle.events.recv().await {
match &ev {
AgentEvent::Request { id, .. } => {
requests += 1;
let d = decision.expect("unexpected approval Request in this phase");
handle
.commands
.send(AgentCommand::Respond {
id: *id,
value: serde_json::json!({ "decision": d }),
})
.unwrap();
}
AgentEvent::TurnComplete { .. } => {
events.push(ev);
break;
}
_ => {}
}
events.push(ev);
}
(events, requests)
}
#[tokio::test]
async fn full_assembly_lifecycle() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
std::env::set_var("ATOMCODE_HOME", home.path());
let cfg = cfg(project.path());
let opts = || PrepareOptions {
skill_dirs: Some(vec![project.path().join("skills")]),
..Default::default()
};
std::fs::write(home.path().join("memory.md"), "- the user prefers tabs\n").unwrap();
let mut parts = prepare(&cfg, opts()).await.unwrap();
let session_id = parts.session.as_ref().unwrap().id.clone();
let sessions_root = parts.session.as_ref().unwrap().manager.root().to_path_buf();
let provider1 = Arc::new(RecordingProvider::new(vec![text_turn("first answer")]));
let calls1 = provider1.calls();
let mut h1 = assemble(&mut parts, &cfg, provider1).unwrap().spawn();
let (_, reqs) = drive(&mut h1, "the first task", None).await;
assert_eq!(reqs, 0, "a text-only turn asks no approval");
h1.commands.send(AgentCommand::Shutdown).unwrap();
let _ = h1.task.await;
{
let calls = calls1.lock().unwrap();
let defs: Vec<&str> = calls[0].1.iter().map(|d| d.name.as_str()).collect();
for expected in [
"bash",
"read_file",
"list_symbols",
"web_fetch",
"use_skill",
"recall",
"code_review",
] {
assert!(
defs.contains(&expected),
"missing tool {expected}: {defs:?}"
);
}
}
{
let calls = calls1.lock().unwrap();
let first = &calls[0].0;
let shape = || {
first
.iter()
.map(|m| (&m.role, m.text[..m.text.len().min(30)].to_string()))
.collect::<Vec<_>>()
};
assert_eq!(first[0].role, Role::System, "persona leads");
assert!(
first[1].role == Role::System && first[1].text.starts_with("=== SESSION CONTEXT ==="),
"session-context block injected after persona: {:?}",
shape()
);
assert!(
first[2].role == Role::System && first[2].text.starts_with("=== MEMORY ==="),
"memory block injected after the context block: {:?}",
shape()
);
assert!(first[2].text.contains("prefers tabs"));
assert_eq!(
first.last().unwrap().role,
Role::User,
"round 1 ends at the user turn"
);
assert!(
!first
.iter()
.any(|m| m.role == Role::User && m.text.contains("<system-reminder>")),
"no status reminder (user tail) on a turn's round 1: {:?}",
shape()
);
}
assert!(
sessions_root
.join(format!("{session_id}.snapshot"))
.exists(),
"snapshot persisted"
);
assert!(
sessions_root.join(format!("{session_id}.meta")).exists(),
"meta persisted"
);
assert!(
sessions_root.join(format!("{session_id}.jsonl")).exists(),
"transcript persisted"
);
let provider1b = Arc::new(RecordingProvider::new(vec![text_turn("post-swap answer")]));
let calls1b = provider1b.calls();
let mut h1b = assemble(&mut parts, &cfg, provider1b).unwrap().spawn();
let _ = drive(&mut h1b, "the swap task", None).await;
h1b.commands.send(AgentCommand::Shutdown).unwrap();
let _ = h1b.task.await;
{
let calls = calls1b.lock().unwrap();
let first = &calls[0].0;
assert!(
first.iter().any(|m| m.text == "the first task"),
"respawn on the same parts carries the conversation"
);
}
drop(parts);
let mut parts2 = prepare(
&cfg,
PrepareOptions {
session: SessionMode::Resume(session_id.clone()),
..opts()
},
)
.await
.unwrap();
let provider2 = Arc::new(RecordingProvider::new(vec![text_turn("second answer")]));
let calls2 = provider2.calls();
let mut h2 = assemble(&mut parts2, &cfg, provider2).unwrap().spawn();
let _ = drive(&mut h2, "the second task", None).await;
h2.commands.send(AgentCommand::Shutdown).unwrap();
let _ = h2.task.await;
{
let calls = calls2.lock().unwrap();
let first = &calls[0].0;
assert!(
first.iter().any(|m| m.text == "the first task"),
"resume carries history"
);
assert!(
first.iter().any(|m| m.text == "the swap task"),
"incl. the respawned turn"
);
assert!(first.iter().any(|m| m.text == "the second task"));
let system_count = first.iter().filter(|m| m.role == Role::System).count();
assert_eq!(
system_count, 3,
"persona + session-context + memory exactly once each (resume reconciles in place, no double-inject)"
);
}
let jsonl = std::fs::read_to_string(sessions_root.join(format!("{session_id}.jsonl"))).unwrap();
let turn_ids: Vec<u64> = jsonl
.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["turn_id"]
.as_u64()
.unwrap()
})
.collect();
assert_eq!(
turn_ids,
vec![1, 2, 3],
"turn ids continue across respawn AND resume — no duplicate keys"
);
let mut parts3 = prepare(
&cfg,
PrepareOptions {
session: SessionMode::Disabled,
..opts()
},
)
.await
.unwrap();
let risky = || ToolCall {
id: "c1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "git reset --hard HEAD~3" }).to_string(),
};
let provider3 = Arc::new(RecordingProvider::new(vec![
vec![
StreamEvent::ToolCall(risky()),
StreamEvent::Done { truncated: false },
],
text_turn("done"),
]));
let mut h3 = assemble(&mut parts3, &cfg, provider3).unwrap().spawn();
let (_, reqs) = drive(&mut h3, "do the risky thing", Some("allow_always")).await;
assert_eq!(reqs, 1, "the risky call asks once");
h3.commands.send(AgentCommand::Shutdown).unwrap();
let _ = h3.task.await;
let provider4 = Arc::new(RecordingProvider::new(vec![
vec![
StreamEvent::ToolCall(risky()),
StreamEvent::Done { truncated: false },
],
text_turn("done again"),
]));
let mut h4 = assemble(&mut parts3, &cfg, provider4).unwrap().spawn();
let (_, reqs) = drive(&mut h4, "again", None).await;
assert_eq!(
reqs, 0,
"allow-always grant survives the respawn (parts own the store)"
);
h4.commands.send(AgentCommand::Shutdown).unwrap();
let _ = h4.task.await;
}