use koda_core::{
bg_agent::AgentStatus, engine::EngineEvent, persistence::Persistence, runtime_env,
};
use koda_test_utils::{ENV_MUTEX, Env, MockProvider, MockResponse};
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sub_agent_invocation_e2e() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
let agents_dir = env.root.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(
agents_dir.join("echo-agent.json"),
serde_json::json!({
"name": "echo-agent",
"system_prompt": "You are a simple echo agent. Repeat back the user's prompt verbatim.",
"allowed_tools": [],
"provider": "mock",
"base_url": "http://localhost:0"
})
.to_string(),
)
.unwrap();
runtime_env::set(
"KODA_MOCK_RESPONSES",
r#"[{"text": "Echo: review the auth module"}]"#,
);
env.insert_user_message("delegate to echo-agent").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({
"agent_name": "echo-agent",
"prompt": "review the auth module"
}),
),
MockResponse::Text("Sub-agent says: Echo: review the auth module".into()),
]);
let events = env.run_inference(&provider).await;
runtime_env::remove("KODA_MOCK_RESPONSES");
assert!(
events.iter().any(
|e| matches!(e, EngineEvent::SubAgentStart { agent_name } if agent_name == "echo-agent")
),
"expected SubAgentStart for echo-agent, got: {events:?}"
);
let tool_result = events.iter().find_map(|e| {
if let EngineEvent::ToolCallResult { output, name, .. } = e
&& name == "InvokeAgent"
{
return Some(output.clone());
}
None
});
assert!(
tool_result.is_some(),
"expected InvokeAgent tool result, got: {events:?}"
);
assert!(
tool_result
.unwrap()
.contains("Echo: review the auth module"),
"sub-agent result should contain echoed prompt"
);
let last = env
.db
.last_assistant_message(&env.session_id)
.await
.unwrap();
assert!(
last.contains("Sub-agent says"),
"final response should reference sub-agent output: {last}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sub_agent_marks_assistant_messages_complete_so_loop_progresses() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
let agents_dir = env.root.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(
agents_dir.join("loop-test-agent.json"),
serde_json::json!({
"name": "loop-test-agent",
"system_prompt": "You are a test agent. Call ListSkills then reply done.",
"allowed_tools": ["ListSkills"],
"provider": "mock",
"base_url": "http://localhost:0"
})
.to_string(),
)
.unwrap();
runtime_env::set(
"KODA_MOCK_RESPONSES",
r#"[
{"tool_calls": [{"id": "tc_1", "name": "ListSkills", "arguments": "{}"}]},
{"text": "sub-agent done"}
]"#,
);
env.insert_user_message("delegate").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({
"agent_name": "loop-test-agent",
"prompt": "do the thing"
}),
),
MockResponse::Text("parent done".into()),
]);
let _events = env.run_inference(&provider).await;
runtime_env::remove("KODA_MOCK_RESPONSES");
let sessions = env.db.list_sessions(10, &env.root).await.unwrap();
let sub_session = sessions
.iter()
.find(|s| s.agent_name == "loop-test-agent")
.unwrap_or_else(|| {
panic!(
"loop-test-agent session must exist; got: {:?}",
sessions.iter().map(|s| &s.agent_name).collect::<Vec<_>>()
)
});
let context = env.db.load_context(&sub_session.id).await.unwrap();
let assistant_turns = context
.iter()
.filter(|m| matches!(m.role, koda_core::persistence::Role::Assistant))
.count();
assert!(
assistant_turns >= 1,
"sub-agent's load_context must include at least one assistant turn; found {assistant_turns}. \
Pre-fix this was zero because mark_message_complete was never called, so every iteration \
the sub-agent saw `[system, user]` only and re-issued the same tool call. Context: {context:#?}"
);
let all = env.db.load_all_messages(&sub_session.id).await.unwrap();
let all_assistant = all
.iter()
.filter(|m| matches!(m.role, koda_core::persistence::Role::Assistant))
.count();
assert_eq!(
all_assistant, assistant_turns,
"every assistant row in the sub-agent session must be visible to load_context; \
all={all_assistant}, filtered={assistant_turns}. Drift = some assistant rows have \
completed_at IS NULL = the loop-spin bug is back."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sub_agent_cache_hit_skips_llm() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
let agents_dir = env.root.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(
agents_dir.join("echo-agent.json"),
serde_json::json!({
"name": "echo-agent",
"system_prompt": "You are a simple echo agent.",
"allowed_tools": [],
"provider": "mock",
"base_url": "http://localhost:0"
})
.to_string(),
)
.unwrap();
runtime_env::set("KODA_MOCK_RESPONSES", r#"[{"text": "cached result"}]"#);
env.insert_user_message("call the agent twice").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({"agent_name": "echo-agent", "prompt": "do the thing"}),
),
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({"agent_name": "echo-agent", "prompt": "do the thing"}),
),
MockResponse::Text("Done with both calls.".into()),
]);
let events = env.run_inference(&provider).await;
runtime_env::remove("KODA_MOCK_RESPONSES");
let cache_hit = events
.iter()
.any(|e| matches!(e, EngineEvent::Info { message } if message.contains("cache hit")));
assert!(cache_hit, "expected cache hit event, got: {events:?}");
let last = env
.db
.last_assistant_message(&env.session_id)
.await
.unwrap();
assert!(
last.contains("Done with both calls"),
"should complete with final response: {last}"
);
}
fn write_agent_config(env: &Env, name: &str, skip_memory: bool) {
let agents_dir = env.root.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(
agents_dir.join(format!("{name}.json")),
serde_json::json!({
"name": name,
"system_prompt": "You are a lean test agent.",
"skip_memory": skip_memory,
"allowed_tools": [],
"provider": "mock",
"base_url": "http://localhost:0"
})
.to_string(),
)
.unwrap();
}
async fn invoke_agent_and_take_calls(
env: &Env,
agent_name: &str,
) -> Vec<Vec<koda_core::providers::ChatMessage>> {
MockProvider::clear_env_calls();
env.insert_user_message(&format!("call {agent_name}")).await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({"agent_name": agent_name, "prompt": "go"}),
),
MockResponse::Text("done".into()),
]);
env.run_inference(&provider).await;
MockProvider::take_env_calls()
}
#[cfg(feature = "test-support")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skip_memory_excludes_project_memory_from_sub_agent() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
std::fs::write(env.root.join("MEMORY.md"), "SENTINEL_XYZ").unwrap();
write_agent_config(&env, "lean-agent", true);
runtime_env::set("KODA_MOCK_RESPONSES", r#"[{"text": "sub done"}]"#);
let calls = invoke_agent_and_take_calls(&env, "lean-agent").await;
runtime_env::remove("KODA_MOCK_RESPONSES");
assert!(
!calls.is_empty(),
"sub-agent provider should have been called"
);
let all_content: String = calls
.iter()
.flatten()
.filter_map(|m| m.content.as_deref())
.collect();
assert!(
!all_content.contains("SENTINEL_XYZ"),
"skip_memory: true must exclude project memory from sub-agent system prompt; got:\n{all_content}"
);
}
#[cfg(feature = "test-support")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn without_skip_memory_project_memory_reaches_sub_agent() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
std::fs::write(env.root.join("MEMORY.md"), "SENTINEL_XYZ").unwrap();
write_agent_config(&env, "full-agent", false);
runtime_env::set("KODA_MOCK_RESPONSES", r#"[{"text": "sub done"}]"#);
let calls = invoke_agent_and_take_calls(&env, "full-agent").await;
runtime_env::remove("KODA_MOCK_RESPONSES");
assert!(
!calls.is_empty(),
"sub-agent provider should have been called"
);
let all_content: String = calls
.iter()
.flatten()
.filter_map(|m| m.content.as_deref())
.collect();
assert!(
all_content.contains("SENTINEL_XYZ"),
"skip_memory: false must include project memory in sub-agent system prompt; got:\n{all_content}"
);
}
#[cfg(feature = "test-support")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sub_agent_invoke_agent_is_refused_with_clear_message() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
write_agent_config(&env, "would-recurse", true);
runtime_env::set(
"KODA_MOCK_RESPONSES",
r#"[{"tool": "InvokeAgent", "args": {"agent_name": "would-recurse", "prompt": "recurse"}}, {"text": "final after refusal"}]"#,
);
env.insert_user_message("delegate").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({"agent_name": "would-recurse", "prompt": "go"}),
),
MockResponse::Text("parent done".into()),
]);
let _events = env.run_inference(&provider).await;
runtime_env::remove("KODA_MOCK_RESPONSES");
let last = env
.db
.last_assistant_message(&env.session_id)
.await
.unwrap();
assert!(
last.contains("parent done"),
"parent must complete after sub-agent refusal cycle; got: {last}"
);
}
#[cfg(feature = "test-support")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bg_agent_iter_counter_advances_via_status_channel() {
let _lock = ENV_MUTEX.lock().await;
let env = Env::new().await;
write_agent_config(&env, "bg-counter-agent", true);
runtime_env::set(
"KODA_MOCK_RESPONSES",
r#"[{"text": "background work done"}]"#,
);
env.insert_user_message("launch background agent").await;
let provider = MockProvider::new(vec![
MockResponse::tool_call(
"InvokeAgent",
serde_json::json!({
"agent_name": "bg-counter-agent",
"prompt": "do some work",
"background": true
}),
),
MockResponse::Text("parent done".into()),
]);
use koda_core::engine::EngineEvent;
let events_from_sink = env.run_inference(&provider).await;
let bg_events = env
.collect_bg_events_after(events_from_sink, Duration::from_secs(10))
.await
.unwrap_or_else(|partial| {
panic!(
"bg task never reached a terminal state within 10s.\n\
bg_events ({} total): {partial:#?}",
partial.len()
)
});
let bg_updates: Vec<&AgentStatus> = bg_events
.iter()
.filter_map(|ev| match ev {
EngineEvent::BgTaskUpdate { status, .. } => Some(status),
_ => None,
})
.collect();
assert!(
!bg_updates.is_empty(),
"expected at least one BgTaskUpdate event; bg_events ({} total): {bg_events:#?}",
bg_events.len()
);
let max_iter_seen = bg_updates
.iter()
.filter_map(|s| match s {
AgentStatus::Running { iter } => Some(*iter),
_ => None,
})
.max();
assert!(
matches!(max_iter_seen, Some(n) if n >= 1),
"expected Running {{ iter >= 1 }}; saw max iter = {max_iter_seen:?}.\nbg_events: {bg_events:#?}"
);
let final_status = bg_updates
.iter()
.rev()
.find(|s| {
matches!(
s,
AgentStatus::Completed { .. }
| AgentStatus::Errored { .. }
| AgentStatus::Cancelled
)
})
.copied()
.unwrap_or_else(|| {
panic!("bg task never reached a terminal state.\nbg_updates: {bg_updates:#?}")
});
match final_status {
AgentStatus::Completed { summary } => {
assert!(
!summary.is_empty(),
"bg agent completed with empty summary — \
execute_sub_agent output was not captured"
);
}
AgentStatus::Errored { error } => panic!("bg agent errored: {error}"),
AgentStatus::Cancelled => panic!("bg agent was unexpectedly cancelled"),
_ => unreachable!("filter above only keeps terminal states"),
}
runtime_env::remove("KODA_MOCK_RESPONSES");
}