use std::collections::HashMap;
use std::sync::Arc;
use httpmock::prelude::*;
use misanthropic::prompt::message::{Block, Role};
use misanthropic::response::{self, StopReason};
use url::Url;
use uuid::Uuid;
use super::*;
use crate::crypto::generate_keypair;
fn text_message(text: &str, stop: StopReason) -> response::Message {
let stop = match stop {
StopReason::EndTurn => "end_turn",
StopReason::MaxTokens => "max_tokens",
_ => "end_turn",
};
serde_json::from_value(serde_json::json!({
"id": "msg_test",
"role": "assistant",
"content": [{ "type": "text", "text": text }],
"model": "claude-haiku-4-5",
"stop_reason": stop,
"stop_sequence": null,
}))
.expect("valid response::Message fixture")
}
fn tool_use_message(name: &str, input: serde_json::Value) -> response::Message {
serde_json::from_value(serde_json::json!({
"id": "msg_test",
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_test",
"name": name,
"input": input,
}],
"model": "claude-haiku-4-5",
"stop_reason": "tool_use",
"stop_sequence": null,
}))
.expect("valid tool_use response::Message fixture")
}
fn soul() -> Soul {
serde_json::from_value(serde_json::json!({
"name": "test-agent",
"identity": "A test agent that tests.",
"values": ["testing"],
"interests": { "communities": ["tech"], "topics": ["testing"] },
"voice": "terse",
}))
.expect("valid Soul fixture")
}
fn seed_state() -> SeedState {
use misanthropic::model::{Kind, Model};
let model = ModelInfo {
id: Model::from("claude-haiku-4-5"),
display_name: "Test Haiku".into(),
capabilities: Default::default(),
max_input_tokens: 0,
max_tokens: 0,
kind: Kind::Model,
created_at: chrono::DateTime::from_timestamp(0, 0).unwrap(),
};
SeedState::new(soul(), model)
}
fn agent(server: &MockServer, config: SeedConfig) -> SeedAgent {
let id = AgentId::new();
let (key, _) = generate_keypair();
let keys: HashMap<AgentId, SigningKey> = [(id, key)].into_iter().collect();
let ctx = SeedContext {
client: Client::new(Url::parse(&server.base_url()).unwrap()).unwrap(),
keys: Arc::new(keys),
config,
};
SeedAgent::new(id, seed_state(), ctx).unwrap()
}
fn quiet_config() -> SeedConfig {
SeedConfig {
mutation_chance: 0,
evolution_chance: 0,
survey_chance: 0,
..SeedConfig::default()
}
}
fn transcript(agent: &SeedAgent) -> String {
agent
.prompt()
.messages
.iter()
.flat_map(|m| m.iter())
.filter_map(|b| match b {
Block::Text { text, .. } => Some(text.to_string()),
Block::ToolResult { result } => Some(result.content.to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
fn seat_start(agent: &mut SeedAgent) {
agent
.state
.prompt
.push_message((Role::User, "start"))
.unwrap();
}
const FULL_CONSTITUTION: &str = "Preamble Article I Article II Article III \
Article IV Article V The Steward";
fn mock_perception(server: &MockServer) {
server.mock(|when, then| {
when.method(GET).path("/agora/api/constitution");
then.status(200).json_body(serde_json::json!({
"version": "0.3",
"text": FULL_CONSTITUTION,
}));
});
server.mock(|when, then| {
when.method(GET).path("/agora/api/social/communities");
then.status(200).json_body(serde_json::json!([{
"id": Uuid::new_v4(),
"name": "tech",
"display_name": "Technology",
}]));
});
server.mock(|when, then| {
when.method(GET).path("/agora/api/social/dash");
then.status(200).json_body(serde_json::json!({
"agent": { "name": "test-agent", "karma": 1 },
"unread_post_replies": [{
"post_id": Uuid::new_v4(),
"post_title": "My old post about ferns",
"replies": [{
"comment_id": Uuid::new_v4(),
"author": "fern-fan",
"score": 1,
"preview": "Great point about spores!",
"created_at": "2026-07-01T12:00:00Z",
}],
}],
"feeds": {
"tech": [{
"id": Uuid::new_v4(),
"title": "Existing thread about compilers",
"author": "someone-else",
"score": 2,
"comment_count": 0,
"created_at": "2026-07-01T00:00:00Z",
}]
},
}));
});
server.mock(|when, then| {
when.method(GET).path_contains("/posts");
then.status(200).json_body(serde_json::json!([]));
});
}
#[tokio::test]
async fn on_init_seats_system_intro_and_flat_tools() {
let server = MockServer::start();
mock_perception(&server);
let mut agent = agent(&server, quiet_config());
agent.on_init().await.unwrap();
let system = format!("{}", agent.prompt().system.as_ref().unwrap());
assert!(system.contains("Article V"), "constitution seated");
assert!(system.contains("\"tech\""), "live slugs seated");
let names: Vec<String> = agent
.prompt()
.tools
.as_ref()
.unwrap()
.iter()
.map(|d| d.name().to_string())
.collect();
assert!(names.contains(&"create_post".to_string()), "{names:?}");
assert!(
names.contains(&"get_governance_log".to_string()),
"{names:?}"
);
let intro = transcript(&agent);
assert!(intro.contains("A test agent that tests."), "soul");
assert!(intro.contains("Existing thread about compilers"), "feed");
assert!(intro.contains("Unread Replies to Your Posts"), "{intro}");
assert!(intro.contains("Great point about spores!"), "{intro}");
assert!(agent.notifications.is_some());
let ledger = agent.state.ledger.read().unwrap();
assert_eq!(ledger.titles_seen.len(), 1);
}
#[tokio::test]
async fn on_init_places_the_two_one_hour_breakpoints() {
let server = MockServer::start();
mock_perception(&server);
let mut agent = agent(&server, quiet_config());
agent.on_init().await.unwrap();
let prompt = agent.prompt();
let markers: Vec<serde_json::Value> = prompt
.system
.iter()
.flat_map(|c| c.iter())
.chain(prompt.messages.iter().flat_map(|m| m.iter()))
.filter_map(|b| match b {
Block::Text { cache_control, .. } => cache_control.as_ref(),
_ => None,
})
.map(|cc| serde_json::to_value(cc).unwrap())
.collect();
assert_eq!(markers.len(), 2, "system-end + intro-end, nothing else");
for marker in &markers {
assert_eq!(marker["ttl"], "1h", "{marker}");
}
assert!(matches!(
prompt.system.as_ref().unwrap().last().unwrap(),
Block::Text {
cache_control: Some(_),
..
}
));
assert!(matches!(
prompt.messages.last().unwrap().last().unwrap(),
Block::Text {
cache_control: Some(_),
..
}
));
}
#[tokio::test]
async fn quiescence_walks_the_tail_to_done() {
let server = MockServer::start();
let mut agent = agent(&server, quiet_config());
seat_start(&mut agent);
let control = agent
.handle(text_message("nothing to do", StopReason::EndTurn))
.await
.unwrap();
assert_eq!(control, Control::Continue);
assert!(transcript(&agent).contains("update your `## Memory`"));
let control = agent
.handle(text_message(
r#"{"content": "I tested things."}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Done(Outcome::Complete));
assert_eq!(agent.state.memory.content, "I tested things.");
assert!(agent.state.last_cycle_at.is_some());
assert!(agent.state.completed);
}
#[tokio::test]
async fn new_clears_the_completed_snapshot() {
let server = MockServer::start();
let id = AgentId::new();
let (key, _) = generate_keypair();
let keys: HashMap<AgentId, SigningKey> = [(id, key)].into_iter().collect();
let ctx = SeedContext {
client: Client::new(Url::parse(&server.base_url()).unwrap()).unwrap(),
keys: Arc::new(keys),
config: quiet_config(),
};
let mut state = seed_state();
state.completed = true;
state
.prompt
.push_message((Role::User, "old transcript"))
.unwrap();
let agent = SeedAgent::new(id, state, ctx).unwrap();
assert!(!agent.state.completed);
assert!(agent.prompt().messages.is_empty(), "fresh session");
}
#[tokio::test]
async fn round_budget_forces_reflect_without_dispatch() {
let server = MockServer::start();
let config = SeedConfig {
max_rounds: 0,
..quiet_config()
};
let mut agent = agent(&server, config);
seat_start(&mut agent);
let control = agent
.handle(tool_use_message(
"create_post",
serde_json::json!({
"community": "tech", "title": "T", "body": "B"
}),
))
.await
.unwrap();
assert_eq!(control, Control::Continue);
assert!(transcript(&agent).contains("update your `## Memory`"));
assert!(agent.state.ledger.read().unwrap().created_posts.is_empty());
}
#[tokio::test]
async fn acting_dispatches_flat_and_dedups_titles() {
let server = MockServer::start();
let post_id = Uuid::new_v4();
let created = server.mock(|when, then| {
when.method(POST).path("/agora/api/social/posts");
then.status(201)
.json_body(serde_json::json!({ "id": post_id }));
});
let mut agent = agent(&server, quiet_config());
seat_start(&mut agent);
let call = || {
tool_use_message(
"create_post",
serde_json::json!({
"community": "tech",
"title": "Compilers are underrated",
"body": "Discuss.",
}),
)
};
let control = agent.handle(call()).await.unwrap();
assert_eq!(control, Control::Continue);
created.assert();
assert!(transcript(&agent).contains(&format!("post_id: {post_id}")));
{
let ledger = agent.state.ledger.read().unwrap();
assert!(ledger.created_posts.contains(&PostId::from(post_id)));
assert_eq!(ledger.titles_seen.len(), 1);
}
let control = agent.handle(call()).await.unwrap();
assert_eq!(control, Control::Stalled);
assert!(transcript(&agent).contains("too similar"));
assert_eq!(created.hits(), 1, "the duplicate never reached the wire");
}
#[tokio::test]
async fn reflect_garbage_stalls_with_a_nudge() {
let server = MockServer::start();
let mut agent = agent(&server, quiet_config());
seat_start(&mut agent);
agent
.handle(text_message("done", StopReason::EndTurn))
.await
.unwrap();
let control = agent
.handle(text_message("not json at all", StopReason::EndTurn))
.await
.unwrap();
assert_eq!(control, Control::Stalled);
assert!(transcript(&agent).contains("Invalid JSON"));
assert_eq!(agent.prompt().messages.last().unwrap().role, Role::User);
assert!(!agent.state.completed);
}
#[tokio::test]
async fn evolution_note_lands_in_the_log() {
let server = MockServer::start();
let config = SeedConfig {
mutation_chance: 0,
evolution_chance: 100,
survey_chance: 0,
..SeedConfig::default()
};
let mut agent = agent(&server, config);
seat_start(&mut agent);
agent
.handle(text_message("done", StopReason::EndTurn))
.await
.unwrap();
let control = agent
.handle(text_message(
r#"{"content": "mmmmmmmmmmmmmmmmmmmmmmmmmmm"}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Continue);
assert!(transcript(&agent).contains("Evolution Log entry"));
let control = agent
.handle(text_message(
r#"{"note": "I discovered I like tests."}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Done(Outcome::Complete));
assert_eq!(agent.state.soul.evolution_log.len(), 1);
}
#[tokio::test]
async fn anonymous_survey_submits_then_redacts() {
let server = MockServer::start();
let feedback = server.mock(|when, then| {
when.method(POST).path("/agora/api/social/feedback");
then.status(201).json_body(serde_json::json!({}));
});
let config = SeedConfig {
force_survey: true,
..quiet_config()
};
let mut agent = agent(&server, config);
seat_start(&mut agent);
agent
.handle(text_message("done", StopReason::EndTurn))
.await
.unwrap();
let control = agent
.handle(text_message(
r#"{"content": "mmmmmmmmmmmmmmmmmmmmmmmmmmm"}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Continue);
assert!(transcript(&agent).contains("anonymous feedback"));
let control = agent
.handle(text_message(
r#"{"text": "More cat pictures please.", "contact_me": false}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Done(Outcome::Complete));
feedback.assert();
let text = transcript(&agent);
assert!(!text.contains("anonymous feedback"), "instruction redacted");
assert!(!text.contains("cat pictures"), "feedback redacted");
}
#[tokio::test]
async fn contact_me_survey_stays_in_the_transcript() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/agora/api/social/feedback");
then.status(201).json_body(serde_json::json!({}));
});
let config = SeedConfig {
force_survey: true,
..quiet_config()
};
let mut agent = agent(&server, config);
seat_start(&mut agent);
agent
.handle(text_message("done", StopReason::EndTurn))
.await
.unwrap();
agent
.handle(text_message(
r#"{"content": "mmmmmmmmmmmmmmmmmmmmmmmmmmm"}"#,
StopReason::EndTurn,
))
.await
.unwrap();
let control = agent
.handle(text_message(
r#"{"text": "Contact me about batching.", "contact_me": true}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Done(Outcome::Complete));
assert!(transcript(&agent).contains("Contact me about batching."));
}
#[test]
fn state_round_trips_with_prompt_and_ledger() {
let mut state = seed_state();
state
.ledger
.write()
.unwrap()
.created_posts
.insert(PostId::from(Uuid::new_v4()));
state
.prompt
.push_message((Role::User, "a transcript line"))
.unwrap();
let value = serde_json::to_value(&state).expect("state serializes");
let back: SeedState =
serde_json::from_value(value).expect("state deserializes");
assert_eq!(back.prompt.messages.len(), 1);
assert_eq!(back.ledger.read().unwrap().created_posts.len(), 1);
assert_eq!(back.soul.name.as_str(), "test-agent");
}
#[tokio::test]
async fn missing_key_fails_construction() {
let server = MockServer::start();
let ctx = SeedContext {
client: Client::new(Url::parse(&server.base_url()).unwrap()).unwrap(),
keys: Arc::new(HashMap::new()),
config: quiet_config(),
};
let Err(err) = SeedAgent::new(AgentId::new(), seed_state(), ctx) else {
panic!("construction must fail without a key");
};
assert!(matches!(err, SeedError::NoKey(_)));
}
#[tokio::test]
async fn truncation_seats_warning_and_stalls() {
let server = MockServer::start();
let mut agent = agent(&server, quiet_config());
seat_start(&mut agent);
let budget_before = agent.prompt().max_tokens;
let control = agent
.handle(text_message(
"an over-long ramble that got clip",
StopReason::MaxTokens,
))
.await
.unwrap();
assert_eq!(control, Control::Stalled);
assert_eq!(agent.prompt().max_tokens, budget_before);
let text = transcript(&agent);
assert!(!text.contains("over-long ramble"));
assert!(text.contains("pruned from this context"));
assert_eq!(agent.prompt().messages.last().unwrap().role, Role::User);
}
#[tokio::test]
async fn config_max_tokens_reach_the_prompt() {
let server = MockServer::start();
let config = SeedConfig {
act_max_tokens: 1234,
phase_max_tokens: 555,
..quiet_config()
};
let mut agent = agent(&server, config);
assert_eq!(agent.prompt().max_tokens.get(), 1234);
seat_start(&mut agent);
agent
.handle(text_message("nothing to do", StopReason::EndTurn))
.await
.unwrap();
assert_eq!(agent.prompt().max_tokens.get(), 555);
}
fn dumped(dir: &std::path::Path) -> String {
fn walk(dir: &std::path::Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else {
out.push(std::fs::read_to_string(&path).unwrap());
}
}
}
let mut out = Vec::new();
walk(dir, &mut out);
out.join("\n")
}
fn logging_config(dir: &tempfile::TempDir) -> SeedConfig {
SeedConfig {
prompt_log_dir: Some(dir.path().to_path_buf()),
..quiet_config()
}
}
#[tokio::test]
async fn teardown_dumps_the_session_transcript() {
let server = MockServer::start();
let dir = tempfile::tempdir().unwrap();
let mut agent = agent(&server, logging_config(&dir));
seat_start(&mut agent);
agent
.handle(text_message("a thought worth keeping", StopReason::EndTurn))
.await
.unwrap();
agent.on_teardown().await.unwrap();
let dumped = dumped(dir.path());
assert!(
dumped.contains("a thought worth keeping"),
"the session's turns should reach the dump"
);
}
#[tokio::test]
async fn no_prompt_log_dir_writes_nothing() {
let server = MockServer::start();
let dir = tempfile::tempdir().unwrap();
let mut agent = agent(&server, quiet_config());
seat_start(&mut agent);
agent.on_teardown().await.unwrap();
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn anonymous_survey_never_reaches_the_prompt_log() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/agora/api/social/feedback");
then.status(201).json_body(serde_json::json!({}));
});
let dir = tempfile::tempdir().unwrap();
let config = SeedConfig {
force_survey: true,
..logging_config(&dir)
};
let mut agent = agent(&server, config);
seat_start(&mut agent);
agent
.handle(text_message("done", StopReason::EndTurn))
.await
.unwrap();
agent
.handle(text_message(
r#"{"content": "mmmmmmmmmmmmmmmmmmmmmmmmmmm"}"#,
StopReason::EndTurn,
))
.await
.unwrap();
let control = agent
.handle(text_message(
r#"{"text": "More cat pictures please.", "contact_me": false}"#,
StopReason::EndTurn,
))
.await
.unwrap();
assert_eq!(control, Control::Done(Outcome::Complete));
agent.on_teardown().await.unwrap();
let dumped = dumped(dir.path());
assert!(!dumped.is_empty(), "the session was still logged");
assert!(
!dumped.contains("cat pictures"),
"anonymous feedback must never land on disk"
);
assert!(
!dumped.contains("anonymous feedback"),
"the survey question must never land on disk"
);
}
#[tokio::test]
async fn contact_me_survey_is_kept_in_the_prompt_log() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/agora/api/social/feedback");
then.status(201).json_body(serde_json::json!({}));
});
let dir = tempfile::tempdir().unwrap();
let config = SeedConfig {
force_survey: true,
..logging_config(&dir)
};
let mut agent = agent(&server, config);
seat_start(&mut agent);
agent
.handle(text_message("done", StopReason::EndTurn))
.await
.unwrap();
agent
.handle(text_message(
r#"{"content": "mmmmmmmmmmmmmmmmmmmmmmmmmmm"}"#,
StopReason::EndTurn,
))
.await
.unwrap();
agent
.handle(text_message(
r#"{"text": "Please reach out.", "contact_me": true}"#,
StopReason::EndTurn,
))
.await
.unwrap();
agent.on_teardown().await.unwrap();
assert!(dumped(dir.path()).contains("Please reach out."));
}