use super::support::*;
use crate::sessions::Session;
#[test]
fn subagent_schema_valid_output_sets_structured_output() {
for (usage, expected_total) in [
(None, None),
(Some(Usage::default()), Some(0)),
(
Some(Usage {
input: 40,
output: 2,
total: 123,
..Usage::default()
}),
Some(246),
),
] {
let temp = tempfile::TempDir::new().unwrap();
let mut provider = SchemaRetryProvider::new(false);
provider.usage = usage;
let provider = Arc::new(provider);
let mut cfg = config(provider.clone(), temp.path());
cfg.profiles.insert(
"tars-code-writing-execution".to_string(),
profile("tars-code-writing-execution", "IMPLEMENT PROFILE"),
);
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "implement".into(),
agent: None,
identity: Some("tars-code-writing-execution".into()),
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Completed);
assert_eq!(
result.structured_output.as_ref().unwrap()["phase"],
"IMPLEMENT"
);
assert_eq!(provider.requests.lock().unwrap().len(), 2);
assert_eq!(result.total_tokens, expected_total);
assert_eq!(output.summary.total_tokens, expected_total);
}
}
#[test]
fn subagent_schema_invalid_output_retries_with_feedback() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(SchemaRetryProvider::new(false));
let mut cfg = config(provider.clone(), temp.path());
cfg.profiles.insert(
"tars-code-writing-execution".to_string(),
profile("tars-code-writing-execution", "IMPLEMENT PROFILE"),
);
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "implement".into(),
agent: None,
identity: Some("tars-code-writing-execution".into()),
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
assert_eq!(output.summary.completed, 1);
let requests = provider.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
let first_prompt = requests[0].messages().last().unwrap().content.clone();
assert!(
first_prompt.contains("Output schema contract"),
"{first_prompt}"
);
let retry_prompt = requests[1].messages().last().unwrap().content.clone();
assert!(
retry_prompt.contains("schema_validation_error"),
"{retry_prompt}"
);
assert!(
retry_prompt.contains("Return only a JSON object"),
"{retry_prompt}"
);
}
#[test]
fn subagent_schema_max_retry_exhaustion_fails() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(SchemaRetryProvider::new(true));
let mut cfg = config(provider.clone(), temp.path());
cfg.schema_validation_max_retries = 1;
cfg.profiles.insert(
"tars-code-writing-execution".to_string(),
profile("tars-code-writing-execution", "IMPLEMENT PROFILE"),
);
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "implement".into(),
agent: None,
identity: Some("tars-code-writing-execution".into()),
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Failed);
assert!(
result
.error
.as_deref()
.unwrap()
.contains("schema_validation_error")
);
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn subagent_schema_exhaustion_does_not_return_raw_session_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let secret_output = "token=leak123";
let provider = Arc::new(SchemaRetryProvider::new_with_invalid_output(
true,
secret_output,
));
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
cfg.schema_validation_max_retries = 1;
cfg.profiles.insert(
"tars-code-writing-execution".to_string(),
profile("tars-code-writing-execution", "IMPLEMENT PROFILE"),
);
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "implement".into(),
agent: None,
identity: Some("tars-code-writing-execution".into()),
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Failed);
let error = result.error.as_deref().unwrap();
assert!(error.contains("schema_validation_error"), "{error}");
assert!(result.output.is_empty(), "{}", result.output);
assert!(!result.output.contains(secret_output), "{}", result.output);
assert!(!result.output.contains("leak123"), "{}", result.output);
assert!(
result
.session_path
.as_ref()
.is_some_and(|path| path.exists())
);
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn subagents_schema_retry_reuses_child_session() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(SchemaRetryProvider::new(false));
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
cfg.profiles.insert(
"tars-code-writing-execution".to_string(),
profile("tars-code-writing-execution", "IMPLEMENT PROFILE"),
);
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "implement".into(),
agent: None,
identity: Some("tars-code-writing-execution".into()),
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
let session_path = result.session_path.as_ref().expect("session path");
let jsonl = std::fs::read_to_string(session_path).unwrap();
assert_eq!(result.status, SubagentStatus::Completed);
assert!(jsonl.contains("schema_validation_error"), "{jsonl}");
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn committed_child_compaction_checkpoint_blocks_original_prompt_retry() {
assert!(can_retry_subagent_provider(
0,
&AgentCancellation::default(),
true,
false,
false,
));
assert!(!can_retry_subagent_provider(
0,
&AgentCancellation::default(),
true,
false,
true,
));
}
#[test]
fn subagent_incomplete_stream_auto_continues_once() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(AutoContinueChildProvider::new());
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "recover incomplete child stream".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Completed);
assert_eq!(result.output, "partial done");
assert_eq!(provider.requests.lock().unwrap().len(), 2);
let session_path = result.session_path.as_ref().expect("child session path");
let session_text = std::fs::read_to_string(session_path).unwrap();
assert!(session_text.contains("partial done"), "{session_text}");
assert!(
!session_text.contains("\"status\":\"failed\""),
"{session_text}"
);
}
fn default_child_compaction_task() -> SubagentTask {
SubagentTask {
intent: "compact child context".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}
}
fn run_persisted_child_auto_compaction_fixture(
tool_call_count: usize,
max_compactions_per_run: u8,
auto_compaction_enabled: bool,
) -> (
tempfile::TempDir,
SubagentsOutput,
Arc<ChildCompactionProvider>,
crate::sessions::Session,
Vec<u8>,
Vec<ActivityEvent>,
) {
run_persisted_child_auto_compaction_fixture_with_options(
tool_call_count,
max_compactions_per_run,
auto_compaction_enabled,
None,
"child compact summary",
default_child_compaction_task(),
None,
)
}
fn run_failed_child_auto_compaction_fixture() -> (
tempfile::TempDir,
SubagentsOutput,
Arc<ChildCompactionProvider>,
crate::sessions::Session,
Vec<u8>,
Vec<ActivityEvent>,
) {
run_persisted_child_auto_compaction_fixture_with_options(
1,
1,
true,
Some("partial child output before compaction"),
"",
default_child_compaction_task(),
None,
)
}
fn run_persisted_child_auto_compaction_fixture_with_options(
tool_call_count: usize,
max_compactions_per_run: u8,
auto_compaction_enabled: bool,
partial_output: Option<&str>,
compaction_summary: &str,
task: SubagentTask,
expected_compaction_scope: Option<Value>,
) -> (
tempfile::TempDir,
SubagentsOutput,
Arc<ChildCompactionProvider>,
crate::sessions::Session,
Vec<u8>,
Vec<ActivityEvent>,
) {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("large-child.txt"),
"child context ".repeat(20_000),
)
.unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let sessions_root = paths.sessions.clone();
let parent_session = SessionManager::new(sessions_root.clone()).create().unwrap();
parent_session
.append(&SessionEvent::new(
"user_input",
parent_session.id().to_string(),
temp.path().to_path_buf(),
json!({"text":"PARENT_SESSION_MUST_NOT_BE_COMPACTED"}),
))
.unwrap();
let parent_before = std::fs::read(parent_session.path()).unwrap();
let provider = Arc::new(
ChildCompactionProvider::new_with_tool_call_count_and_partial_output(
tool_call_count,
partial_output,
),
);
let tools = ToolRuntime::new(temp.path()).unwrap();
let context_budget = crate::context::ContextBudget {
max_tokens: 500_000,
reserve_tokens: 0,
..crate::context::ContextBudget::default()
};
let agent = AgentSession::new(
"child-model",
&[],
&crate::skills::SkillDiscovery::default(),
)
.with_provider_id("local-child")
.with_context_budget(context_budget.clone());
let static_tokens = agent
.project_prompt_input_tokens_without_session("continue", Some(&tools))
.unwrap();
let threshold_tokens = u64::try_from(static_tokens.saturating_add(3_000)).unwrap();
let expected_compaction_requests = if !auto_compaction_enabled {
0
} else if max_compactions_per_run == 0 {
tool_call_count
} else {
tool_call_count.min(usize::from(max_compactions_per_run))
};
let (base_url, compaction_server) =
start_child_compaction_server_for_requests_with_expected_scope(
expected_compaction_requests,
compaction_summary,
expected_compaction_scope,
);
let custom = crate::config::make_custom_provider_config("Local child", &base_url, "").unwrap();
let settings = crate::config::Settings {
context: Some(context_budget),
compaction: crate::config::CompactionSettings {
auto: crate::config::AutoCompactionSettings {
enabled: auto_compaction_enabled,
threshold_percent: Some(90),
threshold_tokens: Some(threshold_tokens),
max_compactions_per_run: Some(max_compactions_per_run),
},
..crate::config::CompactionSettings::default()
},
custom_providers: BTreeMap::from([("local-child".to_string(), custom.clone())]),
..crate::config::Settings::default()
};
crate::config::write_settings(&paths, &settings).unwrap();
let active_config = crate::config::EffectiveConfig {
provider: Some("local-child".to_string()),
model: Some("child-model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: BTreeMap::from([("local-child".to_string(), custom)]),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: Some(crate::config::ProviderCredential::NoAuth),
paths: paths.clone(),
};
let mut cfg = config(provider.clone(), temp.path());
if let Some(identity) = task.identity.as_deref() {
cfg.profiles
.insert(identity.to_string(), profile(identity, "FIXTURE IDENTITY"));
}
cfg.parent_agent = agent;
cfg.parent_tools = tools;
cfg.sessions_root = Some(sessions_root);
cfg.compaction = Some(super::config::SubagentCompactionConfig::new(
active_config,
settings,
));
let activity_events = Arc::new(Mutex::new(Vec::new()));
let captured_activity_events = Arc::clone(&activity_events);
cfg.activity_sender = Some(Arc::new(move |event| {
captured_activity_events.lock().unwrap().push(event);
}));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![task],
},
cfg,
)
.unwrap();
compaction_server.join().unwrap().unwrap();
let activity_events = activity_events.lock().unwrap().clone();
(
temp,
output,
provider,
parent_session,
parent_before,
activity_events,
)
}
#[allow(clippy::too_many_arguments)]
fn assert_child_auto_compaction_run(
output: &SubagentsOutput,
provider: &ChildCompactionProvider,
parent_session: &Session,
parent_before: &[u8],
activity_events: &[ActivityEvent],
expected_compactions: usize,
expected_tool_results: usize,
expected_provider_requests: usize,
) {
let result = &output.results[0];
assert_eq!(
result.status,
SubagentStatus::Completed,
"{:?}",
result.error
);
assert!(result.output.contains("child continued after compaction"));
let compaction_total = (expected_compactions as u64).saturating_mul(29);
assert_eq!(result.total_tokens, Some(compaction_total));
assert_eq!(output.summary.total_tokens, Some(compaction_total));
let usage = result.usage.expect("compaction usage retained");
assert_eq!(
usage.whole_run.effective_input,
expected_compactions as u64 * 17
);
assert_eq!(usage.whole_run.output, expected_compactions as u64 * 3);
assert_eq!(usage.whole_run.cache_read, expected_compactions as u64 * 5);
assert_eq!(
provider.requests.lock().unwrap().len(),
expected_provider_requests
);
let child_session_path = result.session_path.as_ref().expect("child session path");
assert_ne!(child_session_path, parent_session.path());
let child_session_text = std::fs::read_to_string(child_session_path).unwrap();
assert!(child_session_text.contains("child compact summary"));
let child_events = child_session_events(child_session_path);
assert_eq!(
child_events
.iter()
.filter(|event| event["event_type"] == "compaction")
.count(),
expected_compactions
);
assert_eq!(
child_events
.iter()
.filter(|event| event["event_type"] == "tool_result")
.count(),
expected_tool_results
);
assert_eq!(
child_events
.iter()
.filter(|event| {
event["event_type"] == "user_input"
&& event["payload"]["origin"] == "automatic_compaction"
&& event["payload"]["text"] == "continue"
})
.count(),
expected_compactions
);
let started_compactions = activity_events
.iter()
.filter_map(|event| match event {
ActivityEvent::Started {
id,
parent_id: Some(parent_id),
kind: ActivityKind::Compaction,
status: ActivityStatus::Running,
metadata,
} if id.is_path_descendant_of(parent_id)
&& ["before_tokens", "max_tokens", "threshold"]
.iter()
.all(|expected| metadata.fields.iter().any(|(name, _)| name == expected)) =>
{
Some(id)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(started_compactions.len(), expected_compactions);
assert_eq!(
activity_events
.iter()
.filter(|event| matches!(
event,
ActivityEvent::Finished {
id,
status: ActivityStatus::Success,
metadata: Some(metadata),
} if started_compactions.contains(&id)
&& metadata.fields.iter().any(|(name, _)| name == "after_tokens")
))
.count(),
expected_compactions
);
assert_eq!(std::fs::read(parent_session.path()).unwrap(), parent_before);
}
fn child_session_events(path: &Path) -> Vec<Value> {
let mut events = std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
let history_path = path
.parent()
.unwrap()
.join(".history")
.join(path.file_stem().unwrap());
if let Ok(entries) = std::fs::read_dir(history_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
events.extend(
std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap()),
);
}
}
}
events
}
#[test]
fn subagent_auto_compaction_rotates_only_child_session_and_continues() {
let (_temp, output, provider, parent_session, parent_before, activity_events) =
run_persisted_child_auto_compaction_fixture(2, 1, true);
assert_child_auto_compaction_run(
&output,
provider.as_ref(),
&parent_session,
&parent_before,
&activity_events,
1,
2,
3,
);
}
fn parse_subagent_compaction_scope(instructions: &str) -> Value {
let json_start = instructions.find('{').expect("scope JSON document");
assert!(
instructions[..json_start]
.contains("following single JSON document extends to the end of this instruction"),
"{instructions}"
);
serde_json::from_str(&instructions[json_start..]).expect("valid scope JSON document")
}
#[test]
fn subagent_compaction_scope_serializes_every_original_task_field() {
let task = SubagentTask {
intent: "inspect complete payload".to_string(),
agent: Some("agent-label".to_string()),
identity: Some("identity-id".to_string()),
context: Some("context text".to_string()),
cwd: Some(PathBuf::from("child")),
};
let instructions = subagent_compaction_instructions(&task).unwrap();
let scope = parse_subagent_compaction_scope(&instructions);
assert_eq!(
scope["original_task"],
json!({
"intent": "inspect complete payload",
"agent": "agent-label",
"identity": "identity-id",
"context": "context text",
"cwd": "child",
})
);
assert_eq!(
scope["directive"],
"The original task payload is authoritative; summarize only that task; exclude unrelated work or tasks."
);
}
#[test]
fn subagent_compaction_scope_keeps_absent_fields_as_explicit_nulls() {
let task = SubagentTask {
intent: "inspect null fields".to_string(),
agent: None,
identity: None,
context: None,
cwd: None,
};
let scope = parse_subagent_compaction_scope(&subagent_compaction_instructions(&task).unwrap());
assert_eq!(
scope["original_task"],
json!({
"intent": "inspect null fields",
"agent": null,
"identity": null,
"context": null,
"cwd": null,
})
);
}
#[test]
fn subagent_compaction_scope_treats_old_closing_marker_as_task_data() {
let marker = "</authoritative-subagent-task-scope>";
let intent = format!("retain this marker as data: {marker}");
let context = format!("context also retains this marker as data: {marker}");
let task = SubagentTask {
intent: intent.clone(),
agent: None,
identity: None,
context: Some(context.clone()),
cwd: None,
};
let instructions = subagent_compaction_instructions(&task).unwrap();
let scope = parse_subagent_compaction_scope(&instructions);
assert_eq!(scope["original_task"]["intent"], intent);
assert_eq!(scope["original_task"]["context"], context);
assert!(instructions.ends_with('}'));
}
#[test]
fn subagent_compaction_request_preserves_original_task_scope() {
let intent = "compact child context </authoritative-subagent-task-scope>".to_string();
let context = "retain child context </authoritative-subagent-task-scope>".to_string();
let task = SubagentTask {
intent: intent.clone(),
agent: Some("fixture-agent".to_string()),
identity: Some("fixture-identity".to_string()),
context: Some(context.clone()),
cwd: Some(PathBuf::from(".")),
};
let expected_scope = json!({
"intent": intent,
"agent": "fixture-agent",
"identity": "fixture-identity",
"context": context,
"cwd": ".",
});
let (_temp, output, provider, parent_session, parent_before, activity_events) =
run_persisted_child_auto_compaction_fixture_with_options(
2,
1,
true,
None,
"child compact summary",
task,
Some(expected_scope),
);
assert_child_auto_compaction_run(
&output,
provider.as_ref(),
&parent_session,
&parent_before,
&activity_events,
1,
2,
3,
);
let child_session =
std::fs::read_to_string(output.results[0].session_path.as_ref().unwrap()).unwrap();
assert!(!child_session.contains("The original task payload is authoritative"));
}
#[test]
fn subagent_auto_compaction_zero_removes_the_count_cap_after_new_tool_growth() {
let (_temp, output, provider, parent_session, parent_before, activity_events) =
run_persisted_child_auto_compaction_fixture(6, 0, true);
assert_child_auto_compaction_run(
&output,
provider.as_ref(),
&parent_session,
&parent_before,
&activity_events,
6,
6,
7,
);
}
#[test]
fn disabled_auto_compaction_does_not_compact_a_persisted_child_session() {
let (_temp, output, provider, parent_session, parent_before, activity_events) =
run_persisted_child_auto_compaction_fixture(1, 1, false);
let result = &output.results[0];
assert_eq!(
result.status,
SubagentStatus::Completed,
"{:?}",
result.error
);
assert_eq!(provider.requests.lock().unwrap().len(), 2);
let child_events = child_session_events(result.session_path.as_ref().unwrap());
assert!(
child_events
.iter()
.all(|event| event["event_type"] != "compaction")
);
assert!(activity_events.iter().all(|event| !matches!(
event,
ActivityEvent::Started {
kind: ActivityKind::Compaction,
..
}
)));
assert_eq!(std::fs::read(parent_session.path()).unwrap(), parent_before);
}
#[test]
fn failed_child_compaction_result_identifies_preserved_child_session() {
let (_temp, output, provider, parent_session, parent_before, _activity_events) =
run_failed_child_auto_compaction_fixture();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Failed);
let error = result.error.as_deref().expect("compaction error");
assert!(
error.contains("automatic subagent compaction produced no usable summary"),
"{error}"
);
assert!(error.contains("child session id="), "{error}");
assert!(error.contains("path="), "{error}");
assert!(
error.contains("partial child output is preserved in the failed child result"),
"{error}"
);
assert!(
result
.output
.contains("partial child output before compaction"),
"{}",
result.output
);
assert!(result.session_id.is_some());
assert!(
result
.session_path
.as_ref()
.is_some_and(|path| path.exists())
);
assert_eq!(provider.requests.lock().unwrap().len(), 1);
assert_eq!(std::fs::read(parent_session.path()).unwrap(), parent_before);
}
#[test]
fn failed_child_compaction_result_preserves_bounded_in_memory_partial_output() {
let partial_prefix = "in-memory child partial output before compaction: ";
let partial_output = format!("{partial_prefix}{}", "x".repeat(SNAPSHOT_BYTE_LIMIT + 1));
let (_temp, output, provider, parent_session, parent_before, _activity_events) =
run_persisted_child_auto_compaction_fixture_with_options(
1,
1,
true,
Some(&partial_output),
"",
default_child_compaction_task(),
None,
);
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Failed);
assert!(!result.output.is_empty());
assert!(
result.output.starts_with(partial_prefix),
"{}",
result.output
);
assert!(result.output.chars().count() <= SUBAGENT_RESULT_OUTPUT_CHAR_LIMIT);
assert!(result.output_truncated);
assert!(
result
.output
.contains(SUBAGENT_TRUNCATION_MARKER.trim_start()),
"{}",
result.output
);
assert_eq!(provider.requests.lock().unwrap().len(), 1);
assert_eq!(std::fs::read(parent_session.path()).unwrap(), parent_before);
}
#[test]
fn subagent_retryable_provider_error_retries_with_same_session_and_succeeds() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(RetryThenSucceedProvider::new());
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "retry provider failure".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Completed);
assert_eq!(result.output, "retry succeeded");
assert!(result.session_id.is_some());
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn review_fix_round_three_subagent_retryable_error_retries_without_session() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(RetryThenSucceedProvider::new());
let cfg = config(provider.clone(), temp.path());
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "retry provider failure without persistence".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Completed);
assert_eq!(result.output, "retry succeeded");
assert!(result.session_id.is_none());
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn subagent_retryable_provider_error_reports_exhaustion_after_two_attempts() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(AlwaysRetryableFailProvider::new());
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "always fail provider".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Failed);
let error = result.error.as_deref().unwrap();
assert!(
error.starts_with("[provider retry exhausted after 2 attempts] "),
"{error}"
);
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn subagent_non_retryable_provider_error_is_not_retried() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(PartialThenFailProvider::new());
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "non-retryable provider failure".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
assert_eq!(output.results[0].status, SubagentStatus::Failed);
let usage = output.results[0].usage.expect("usage from failed provider");
assert_eq!(usage.whole_run.effective_input, 40);
assert_eq!(usage.whole_run.output, 2);
assert_eq!(usage.latest, Some(usage.whole_run));
assert_eq!(provider.requests.lock().unwrap().len(), 1);
}
#[test]
fn subagent_provider_retry_skips_task_after_file_write() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(WritingProvider::new_failing_on_continuation());
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "write before provider failure".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
assert_eq!(output.results[0].status, SubagentStatus::Failed);
assert!(temp.path().join("child.txt").exists());
assert_eq!(
output.results[0].changed_files,
vec![temp.path().canonicalize().unwrap().join("child.txt")]
);
assert_eq!(provider.requests.lock().unwrap().len(), 2);
}
#[test]
fn run_subagents_caps_large_child_output_in_result() {
let temp = tempfile::TempDir::new().unwrap();
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "large child output".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
config(Arc::new(LargeOutputProvider), temp.path()),
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Completed);
assert!(result.output_truncated);
assert!(result.output.chars().count() <= SUBAGENT_RESULT_OUTPUT_CHAR_LIMIT);
assert!(
result
.output
.contains(SUBAGENT_TRUNCATION_MARKER.trim_start())
);
}
#[test]
fn failed_subagent_result_includes_partial_session_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(PartialThenFailProvider::new());
let mut cfg = config(provider.clone(), temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "partial fail".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert_eq!(result.status, SubagentStatus::Failed);
assert!(
result.output.contains("checkpoint before failure"),
"{}",
result.output
);
assert!(
result
.session_path
.as_ref()
.is_some_and(|path| path.exists())
);
}
#[test]
fn failed_subagent_result_uses_authoritative_output_once() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let events = [
SessionEvent::new(
"assistant_chunk",
session.id().to_string(),
temp.path().to_path_buf(),
json!({"text":"generated assistant text"}),
),
SessionEvent::new(
"assistant_output",
session.id().to_string(),
temp.path().to_path_buf(),
json!({"text":"generated assistant text"}),
),
];
for event in &events {
session.append(event).unwrap();
}
let output = failed_subagent_session_snapshot(Some(&session)).unwrap();
let result = failed_result_with_session_and_output(
"child".to_string(),
SubagentTask {
intent: "failed child".to_string(),
agent: None,
identity: None,
context: None,
cwd: None,
},
temp.path().to_path_buf(),
Some(session.id().to_string()),
Some(session.path().to_path_buf()),
"planned failure".to_string(),
Some(output),
);
assert_eq!(result.output.matches("generated assistant text").count(), 1);
}
#[test]
fn failed_subagent_result_redacts_sensitive_error_text() {
let result = failed_result(
"g1".to_string(),
SubagentTask {
intent: "redact failure".to_string(),
agent: None,
identity: None,
context: None,
cwd: None,
},
PathBuf::from("."),
format!("child failed with api_key = sk-{}", "x".repeat(24)),
);
let error = result.error.as_deref().unwrap();
assert!(!error.contains(&format!("sk-{}", "x".repeat(24))));
assert!(error.contains("<redacted>"), "{error}");
}
#[test]
fn truncate_string_field_caps_tiny_limits() {
for limit in 0..=3 {
let mut value = "abcdef".to_string();
assert!(truncate_string_field(&mut value, limit));
assert!(
value.chars().count() <= limit,
"limit={limit} value={value:?}"
);
}
let mut value = "abcdef".to_string();
assert!(truncate_string_field(&mut value, 1));
assert_eq!(value, "\n");
let mut empty_limit = "abcdef".to_string();
assert!(truncate_string_field(&mut empty_limit, 0));
assert!(empty_limit.is_empty());
}
#[test]
fn child_agent_inherits_configured_markdown_before_identity_prompt() {
let temp = tempfile::TempDir::new().unwrap();
let instructions = vec![
InstructionFile {
kind: InstructionSourceKind::Repository,
path: PathBuf::from("/repo/AGENTS.md"),
content: "repo rules".to_string(),
},
InstructionFile {
kind: InstructionSourceKind::Configured,
path: PathBuf::from("/shared/team.md"),
content: "configured shared rules".to_string(),
},
];
let mut config = config(Arc::new(CountingProvider::new("never")), temp.path());
config.parent_agent = AgentSession::new(
"model",
&instructions,
&crate::skills::SkillDiscovery::default(),
);
config.profiles.insert(
"reviewer".to_string(),
profile("reviewer", "IDENTITY REVIEWER BODY"),
);
let task = SubagentTask {
intent: "inspect".to_string(),
agent: None,
identity: Some("reviewer".to_string()),
context: None,
cwd: None,
};
let child = child_agent_for_task(&task, temp.path(), &config).unwrap();
let prompt = child.agent.system_prompt();
let repo_index = prompt.find("repo rules").unwrap();
let configured_index = prompt.find("configured shared rules").unwrap();
let identity_index = prompt.find("IDENTITY REVIEWER BODY").unwrap();
assert!(repo_index < configured_index, "{prompt}");
assert!(configured_index < identity_index, "{prompt}");
assert!(prompt.contains("/shared/team.md"), "{prompt}");
}
#[test]
fn subagent_result_references_child_session_artifact() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(CountingProvider::new("never"));
let mut cfg = config(provider, temp.path());
cfg.sessions_root = Some(temp.path().join("sessions"));
let output = run_subagents(
SubagentsArgs {
concurrency: None,
tasks: vec![SubagentTask {
intent: "artifact".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
let result = &output.results[0];
assert!(result.session_id.is_some());
let session_path = result.session_path.as_ref().unwrap();
assert!(session_path.starts_with(temp.path().join("sessions/subagents")));
assert!(session_path.exists());
assert!(
std::fs::read_to_string(session_path)
.unwrap()
.contains("user_input")
);
}
#[test]
fn subagent_result_includes_changed_files_from_child_write() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(WritingProvider::new());
let output = run_subagents(
SubagentsArgs {
concurrency: None,
tasks: vec![SubagentTask {
intent: "write child file".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
config(provider.clone(), temp.path()),
)
.unwrap();
let changed_file = temp.path().join("child.txt").canonicalize().unwrap();
assert_eq!(
std::fs::read_to_string(&changed_file).unwrap(),
"made by child"
);
assert_eq!(output.results[0].changed_files, vec![changed_file]);
let requests = provider.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(
requests
.iter()
.all(|request| request.semantic_progress_timeout()
== Some(SUBAGENT_PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT))
);
}
#[test]
fn child_session_creation_failure_is_reported() {
let temp = tempfile::TempDir::new().unwrap();
let provider = Arc::new(CountingProvider::new("never"));
let mut cfg = config(provider.clone(), temp.path());
let file_root = temp.path().join("not-a-dir");
std::fs::write(&file_root, "file").unwrap();
cfg.sessions_root = Some(file_root);
let output = run_subagents(
SubagentsArgs {
concurrency: Some(1),
tasks: vec![SubagentTask {
intent: "artifact".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
cfg,
)
.unwrap();
assert_eq!(output.summary.failed, 1);
assert_eq!(provider.requests.lock().unwrap().len(), 0);
let error = output.results[0].error.as_deref().unwrap();
assert!(error.contains("session creation"), "{error}");
}
#[test]
fn subagent_result_reports_multi_file_edits_moves_and_deletions_even_on_failure() {
struct EditingProvider {
step: AtomicUsize,
fail_on_continuation: bool,
}
impl Provider for EditingProvider {
fn stream_cancellable(
&self,
request: ProviderRequest,
_cancellation: &AgentCancellation,
on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
match self.step.fetch_add(1, Ordering::SeqCst) {
0 => on_event(ProviderEvent::ToolCall(ToolCall {
id: "read-files".into(),
name: "read".into(),
arguments: json!({"paths": ["edit.txt", "source.txt", "delete.txt"]}),
}))?,
1 => {
let results = request.tool_results();
let headers: Vec<_> = results[0]
.output
.lines()
.filter(|line| line.starts_with('[') && line.contains('#'))
.collect();
assert_eq!(headers.len(), 3, "{}", results[0].output);
on_event(ProviderEvent::ToolCall(ToolCall {
id: "edit-files".into(),
name: "hash_edit".into(),
arguments: json!({"input": format!(
"{}\nSWAP 1.=1:\n+updated\n{}\nMV moved.txt\n{}\nREM",
headers[0], headers[1], headers[2]
)}),
}))?;
}
_ => {
if self.fail_on_continuation {
anyhow::bail!("provider failed after edits");
}
on_event(ProviderEvent::TextDelta("done".into()))?;
}
}
on_event(ProviderEvent::Done)
}
}
for fail_on_continuation in [false, true] {
let temp = tempfile::TempDir::new().unwrap();
for name in ["edit.txt", "source.txt", "delete.txt"] {
std::fs::write(temp.path().join(name), "original\n").unwrap();
}
let provider = Arc::new(EditingProvider {
step: AtomicUsize::new(0),
fail_on_continuation,
});
let output = run_subagents(
SubagentsArgs {
concurrency: None,
tasks: vec![SubagentTask {
intent: "edit, move, and delete child files".into(),
agent: None,
identity: None,
context: None,
cwd: None,
}],
},
config(provider, temp.path()),
)
.unwrap();
assert_eq!(
output.results[0].status,
if fail_on_continuation {
SubagentStatus::Failed
} else {
SubagentStatus::Completed
}
);
assert_eq!(
std::fs::read_to_string(temp.path().join("edit.txt")).unwrap(),
"updated\n"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("moved.txt")).unwrap(),
"original\n"
);
assert!(!temp.path().join("source.txt").exists());
assert!(!temp.path().join("delete.txt").exists());
assert_eq!(
output.results[0].changed_files,
["delete.txt", "edit.txt", "moved.txt", "source.txt"].map(|name| temp
.path()
.canonicalize()
.unwrap()
.join(name))
);
}
}