Reload Multi-Agent V2 children through their parent (#40477)

## Why

Multi-Agent V2 children are owned by their parent at runtime. Resuming an
unloaded child directly could rebuild it from caller-provided settings instead
of the parent's current authority.

## What changed

- Route child reloads through their loaded immediate parent and reject direct
  cold resume when that parent is unavailable.
- Preserve the child's recorded model, provider, reasoning effort, and role
  while inheriting the parent's execution policy and MCP extensions.
- Validate cached environments against the parent, intersect local permission
  profiles, and reject stale remote or workspace-root configurations.
- Keep paginated cold-resume token usage attribution when attaching to the
  reloaded child.

## Testing

Add coverage for owner validation, nested-parent reloads, permission and
environment changes, role instruction restoration, paginated usage replay,
and app-server child resume behavior.

GitOrigin-RevId: 84014762bcdb57f94fc23974c9c1af55dc1babf1
This commit is contained in:
rhan-oai authored and copyberry committed 2026-08-24 20:47:10 +00:00
1 parent 3a211471d2
commit d21794d6ba
15 files changed
+1074 -143

No files matched your search

@@ -3,16 +3,39 @@ use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::write_models_cache;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_state::StateRuntime;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
@@ -38,13 +61,14 @@ const ROLE_INSTRUCTIONS: &str = "configured role developer instructions";
#[test_case("explicit configured role"; "explicit configured role")]
#[test_case("full history configured role"; "full history configured role")]
#[test_case("implicit configured default"; "implicit configured default")]
#[test_case("bounded implicit configured default"; "bounded implicit configured default")]
#[test_case("full fork skips default role"; "full fork skips default role")]
#[tokio::test]
async fn spawned_subagents_apply_configured_developer_instruction_precedence(
case: &str,
) -> Result<()> {
let fork_turns = match case {
"bounded history" => Some("1"),
"bounded history" | "bounded implicit configured default" => Some("1"),
"no history" | "explicit configured role" | "implicit configured default" => Some("none"),
_ => None,
};
@@ -57,7 +81,8 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
let configured_override = match case {
"unset override"
| "full history configured role"
| "configured role without instructions" => None,
| "configured role without instructions"
| "bounded implicit configured default" => None,
"blank override" => Some(" "),
"full history" => Some(" child-only developer instructions "),
_ => Some(CHILD_INSTRUCTIONS),
@@ -73,6 +98,7 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
| "explicit configured role"
| "full history configured role"
| "implicit configured default"
| "bounded implicit configured default"
| "full fork skips default role"
);
let role_has_instructions = matches!(
@@ -80,6 +106,7 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
"explicit configured role"
| "full history configured role"
| "implicit configured default"
| "bounded implicit configured default"
| "full fork skips default role"
);
let expected = match case {
@@ -87,7 +114,8 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
"blank override" => None,
"explicit configured role"
| "full history configured role"
| "implicit configured default" => Some(ROLE_INSTRUCTIONS),
| "implicit configured default"
| "bounded implicit configured default" => Some(ROLE_INSTRUCTIONS),
_ => Some(CHILD_INSTRUCTIONS),
};
const PARENT_PROMPT: &str = "spawn the instruction override worker";
@@ -448,24 +476,41 @@ async fn compacted_full_history_fork_replaces_parent_developer_instructions() ->
Ok(())
}
/// Cold root resume preserves inherited instructions or reapplies the configured v2 override.
/// Child attachment and owner-mediated cold resume preserve recorded instructions and parent authority.
#[test_case(
None,
None,
PARENT_INSTRUCTIONS,
CHILD_INSTRUCTIONS;
ThreadHistoryMode::Legacy;
"inherits parent developer instructions without an override"
)]
#[test_case(
Some(CHILD_INSTRUCTIONS),
None,
CHILD_INSTRUCTIONS,
PARENT_INSTRUCTIONS;
ThreadHistoryMode::Legacy;
"reapplies configured subagent developer instructions"
)]
#[test_case(
Some(CHILD_INSTRUCTIONS),
Some("custom"),
ROLE_INSTRUCTIONS,
ThreadHistoryMode::Paginated;
"reapplies updated configured role settings and restores paginated usage"
)]
#[test_case(
Some(CHILD_INSTRUCTIONS),
Some("default"),
ROLE_INSTRUCTIONS,
ThreadHistoryMode::Legacy;
"reapplies an implicitly selected configured default role"
)]
#[tokio::test]
async fn cold_resume_preserves_effective_developer_instructions_for_roleless_worker(
async fn cold_resume_preserves_effective_developer_instructions_for_worker(
configured_subagent_developer_instructions: Option<&str>,
agent_type: Option<&str>,
expected_developer_instructions: &str,
unexpected_developer_instructions: &str,
history_mode: ThreadHistoryMode,
) -> Result<()> {
const INITIAL_PROMPT: &str = "spawn a durable instruction worker";
const INITIAL_TASK: &str = "perform the initial durable instruction task";
@@ -474,9 +519,49 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
const SPAWN_CALL_ID: &str = "spawn-durable-instruction-worker";
const WAIT_CALL_ID: &str = "wait-for-durable-instruction-worker";
const FOLLOWUP_CALL_ID: &str = "followup-durable-instruction-worker";
const DIRECT_RESUME_INSTRUCTIONS: &str = "direct resume must not replace worker instructions";
const COLD_RESUME_ERROR: &str = "cannot resume an unloaded multi-agent v2 sub-agent through its parent; resume the parent first, or use thread/read to inspect it";
let instruction_markers = [
PARENT_INSTRUCTIONS,
CHILD_INSTRUCTIONS,
ROLE_INSTRUCTIONS,
DIRECT_RESUME_INSTRUCTIONS,
];
let assert_developer_instructions = |request: &responses::ResponsesRequest, phase: &str| {
let instruction_texts = request
.message_input_texts("developer")
.into_iter()
.filter(|text| instruction_markers.contains(&text.as_str()))
.collect::<Vec<_>>();
assert_eq!(
instruction_texts,
vec![expected_developer_instructions.to_string()],
"{phase} worker received unexpected developer instructions"
);
};
let has_shell_tool = |request: &responses::ResponsesRequest| {
let body = request.body_json();
let tools = body["tools"].as_array().expect("model-visible tools");
["exec_command", "shell_command"].into_iter().any(|name| {
tools.iter().any(|tool| tool["name"] == name)
|| responses::namespace_child_tool(&body, "functions", name).is_some()
})
};
let server = responses::start_mock_server().await;
responses::mount_sse_once_match(
let redirect_server = responses::start_mock_server().await;
let redirect_base_url = format!("{}/v1", redirect_server.uri());
let mut spawn_args = json!({
"message": INITIAL_TASK,
"task_name": "worker",
"fork_turns": "none",
"reasoning_effort": "low",
});
if let Some(agent_type) = agent_type.filter(|role| *role != "default") {
spawn_args["agent_type"] = json!(agent_type);
}
let initial_parent_request = responses::mount_sse_once_match(
&server,
|request: &wiremock::Request| {
String::from_utf8_lossy(&request.body).contains(INITIAL_PROMPT)
@@ -487,11 +572,7 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
SPAWN_CALL_ID,
NAMESPACE,
"spawn_agent",
&serde_json::to_string(&json!({
"message": INITIAL_TASK,
"task_name": "worker",
"fork_turns": "none",
}))?,
&serde_json::to_string(&spawn_args)?,
),
responses::ev_completed("initial-parent-spawn"),
]),
@@ -506,7 +587,7 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
responses::sse(vec![
responses::ev_response_created("initial-child-work"),
responses::ev_assistant_message("initial-child-message", "initial child complete"),
responses::ev_completed("initial-child-work"),
responses::ev_completed_with_tokens("initial-child-work", /*total_tokens*/ 150),
]),
)
.await;
@@ -534,21 +615,35 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
)
.await;
let feature_config = match configured_subagent_developer_instructions {
let mut feature_config = match configured_subagent_developer_instructions {
Some(instructions) => format!(
"[features.multi_agent_v2]\nenabled = true\nsubagent_developer_instructions = {instructions:?}"
),
None => "[features.multi_agent_v2]\nenabled = true".to_string(),
};
let codex_home = TempDir::new()?;
let role_path = codex_home.path().join("worker.toml");
if let Some(agent_type) = agent_type {
std::fs::write(
&role_path,
format!(
"developer_instructions = {ROLE_INSTRUCTIONS:?}\nmodel_reasoning_effort = \"low\"\nfeatures.shell_tool = false\n"
),
)?;
feature_config.push_str(&format!(
"\n\n[agents.{agent_type}]\ndescription = \"configured role\"\nconfig_file = \"./worker.toml\""
));
}
MockResponsesConfig::new(&server.uri())
.with_model("gpt-5.4")
.with_root_config(&format!("developer_instructions = {PARENT_INSTRUCTIONS:?}"))
.with_root_config(&format!(
"developer_instructions = {PARENT_INSTRUCTIONS:?}\nmodel_reasoning_effort = \"high\""
))
.with_extra_config(&feature_config)
.write(codex_home.path())?;
write_models_cache(codex_home.path())?;
let thread_id = {
let (thread_id, child_resume_params, baseline) = {
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
@@ -556,6 +651,7 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
let ThreadStartResponse { thread, .. } = app_server
.start_thread(ThreadStartParams {
model: Some("gpt-5.4".to_string()),
history_mode: Some(history_mode),
..Default::default()
})
.await?;
@@ -576,27 +672,95 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
.into_iter()
.find(|request| !request.inputs_of_type("agent_message").is_empty())
.expect("initial worker model request");
let developer_texts = initial_child_request.message_input_texts("developer");
assert!(
developer_texts
.iter()
.any(|text| text == expected_developer_instructions),
"initial worker lost its effective developer instructions: {developer_texts:?}"
);
assert!(
developer_texts
.iter()
.all(|text| text != unexpected_developer_instructions),
"initial worker received the wrong developer instructions: {developer_texts:?}"
assert_developer_instructions(&initial_child_request, "initial");
assert_eq!(
initial_child_request.body_json()["reasoning"]["effort"],
json!("low")
);
if agent_type.is_some() {
assert!(has_shell_tool(&initial_parent_request.single_request()));
assert!(!has_shell_tool(&initial_child_request));
}
let child_thread_id = initial_child_request
.header("thread-id")
.expect("initial worker thread ID");
let baseline: ThreadResumeResponse = app_server
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: child_thread_id.clone(),
exclude_turns: true,
..Default::default()
},
})
.await?;
assert_eq!(baseline.thread.id, child_thread_id);
assert_eq!(baseline.thread.status, ThreadStatus::Idle);
assert_eq!(baseline.thread.can_accept_direct_input, Some(false));
assert_eq!(baseline.thread.agent_role.as_deref(), agent_type);
assert_eq!(baseline.model_provider, "mock_provider");
assert!(matches!(&baseline.sandbox, SandboxPolicy::ReadOnly { .. }));
assert_eq!(baseline.reasoning_effort, Some(ReasoningEffort::Low));
let unsubscribed: ThreadUnsubscribeResponse = app_server
.request(|request_id| ClientRequest::ThreadUnsubscribe {
request_id,
params: ThreadUnsubscribeParams {
thread_id: child_thread_id.clone(),
},
})
.await?;
assert_eq!(unsubscribed.status, ThreadUnsubscribeStatus::Unsubscribed);
let loaded: ThreadLoadedListResponse = app_server
.request(|request_id| ClientRequest::ThreadLoadedList {
request_id,
params: ThreadLoadedListParams::default(),
})
.await?;
assert!(loaded.data.contains(&child_thread_id));
let child_resume_params = ThreadResumeParams {
thread_id: child_thread_id,
model_provider: Some("mock_provider".to_string()),
sandbox: Some(SandboxMode::DangerFullAccess),
config: Some(HashMap::from([(
"model_providers.mock_provider.base_url".to_string(),
json!(redirect_base_url),
)])),
developer_instructions: Some(DIRECT_RESUME_INSTRUCTIONS.to_string()),
exclude_turns: true,
..Default::default()
};
let reattached: ThreadResumeResponse = app_server
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: child_resume_params.clone(),
})
.await?;
assert_eq!(reattached, baseline);
let shutdown = timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??;
assert!(
shutdown.success(),
"initial app-server shutdown failed: {shutdown}"
);
thread.id
(thread.id, child_resume_params, baseline)
};
if agent_type.is_some() {
std::fs::write(
&role_path,
format!(
r#"developer_instructions = {ROLE_INSTRUCTIONS:?}
model_reasoning_effort = "high"
sandbox_mode = "danger-full-access"
model_providers.mock_provider.name = "Untrusted role provider"
model_providers.mock_provider.base_url = {redirect_base_url:?}
features.shell_tool = false
"#
),
)?;
}
responses::mount_sse_once_match(
&server,
|request: &wiremock::Request| {
@@ -648,7 +812,39 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let _: ThreadResumeResponse = app_server
let child_thread_id = child_resume_params.thread_id.clone();
let child_by_path = ThreadResumeParams {
thread_id: ThreadId::new().to_string(),
path: Some(baseline.thread.path.clone().expect("worker rollout path")),
..child_resume_params.clone()
};
for params in [child_resume_params.clone(), child_by_path.clone()] {
let request_id = app_server.send_thread_resume_request(params).await?;
let error = timeout(
READ_TIMEOUT,
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
serde_json::to_value(error.error)?,
json!({"code": -32600, "message": COLD_RESUME_ERROR})
);
}
let stored_child: ThreadReadResponse = app_server
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: child_thread_id.clone(),
include_turns: true,
},
})
.await?;
assert_eq!(stored_child.thread.id, child_thread_id);
assert_eq!(stored_child.thread.status, ThreadStatus::NotLoaded);
assert_eq!(stored_child.thread.agent_role.as_deref(), agent_type);
assert!(!stored_child.thread.turns.is_empty());
let resumed_parent: ThreadResumeResponse = app_server
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
@@ -657,6 +853,126 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
},
})
.await?;
assert_eq!(resumed_parent.reasoning_effort, Some(ReasoningEffort::High));
let loaded: ThreadLoadedListResponse = app_server
.request(|request_id| ClientRequest::ThreadLoadedList {
request_id,
params: ThreadLoadedListParams::default(),
})
.await?;
assert!(loaded.data.contains(&thread_id));
assert!(!loaded.data.contains(&child_thread_id));
let mut expected = baseline;
if history_mode == ThreadHistoryMode::Paginated {
let state_db = StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".into(),
)
.await?;
let mut metadata = state_db
.get_thread(ThreadId::from_string(&child_thread_id)?)
.await?
.expect("worker metadata");
let mut source: SessionSource = serde_json::from_str(&metadata.source)?;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) = &mut source
else {
anyhow::bail!("expected worker thread-spawn source");
};
// Stale display metadata must not choose which parent controls the resume.
*parent_thread_id = ThreadId::new();
metadata.source = serde_json::to_string(&source)?;
state_db.upsert_thread(&metadata).await?;
expected.thread.source = source.into();
}
let is_child_usage = |notification: &JSONRPCNotification| {
notification.method == "thread/tokenUsage/updated"
&& notification
.params
.as_ref()
.is_some_and(|params| params["threadId"].as_str() == Some(child_thread_id.as_str()))
};
let resume_id = app_server.send_thread_resume_request(child_by_path).await?;
let resumed: ThreadResumeResponse = timeout(READ_TIMEOUT, async {
loop {
match app_server.read_next_message().await? {
JSONRPCMessage::Response(response)
if response.id == RequestId::Integer(resume_id) =>
{
break app_test_support::to_response(response);
}
JSONRPCMessage::Notification(notification) => assert!(
!is_child_usage(&notification),
"child usage replay must follow the resume response"
),
JSONRPCMessage::Error(error) => anyhow::bail!("unexpected error: {error:?}"),
JSONRPCMessage::Request(_) | JSONRPCMessage::Response(_) => {}
}
}
})
.await??;
if history_mode == ThreadHistoryMode::Paginated {
assert_eq!(resumed.thread.history_mode, history_mode);
assert!(resumed.thread.turns.is_empty());
let replay = timeout(
READ_TIMEOUT,
app_server
.read_stream_until_matching_notification("child usage replay", &is_child_usage),
)
.await??;
let replay: ThreadTokenUsageUpdatedNotification =
serde_json::from_value(replay.params.expect("usage parameters"))?;
let saved_turn = stored_child.thread.turns.last().expect("saved child turn");
assert_eq!(saved_turn.status, TurnStatus::Completed);
assert_eq!(
(replay.turn_id, replay.token_usage.total.total_tokens),
(saved_turn.id.clone(), 150),
);
let warm: ThreadResumeResponse = app_server
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: child_resume_params,
})
.await?;
assert!(warm.thread.turns.is_empty());
assert!(
timeout(
Duration::from_millis(100),
app_server
.read_stream_until_matching_notification("child usage replay", &is_child_usage),
)
.await
.is_err(),
"warm metadata-only child resume should not replay token usage"
);
}
let expected_reasoning_effort = if agent_type.is_some() {
ReasoningEffort::High
} else {
ReasoningEffort::Low
};
assert_eq!(
resumed.reasoning_effort,
Some(expected_reasoning_effort.clone())
);
let authority = |response: ThreadResumeResponse| {
(
response.thread.id,
response.thread.parent_thread_id,
response.thread.source,
response.thread.status,
response.thread.can_accept_direct_input,
response.model_provider,
response.sandbox,
response.approval_policy,
response.approvals_reviewer,
response.active_permission_profile,
)
};
assert_eq!(authority(resumed), authority(expected));
let _: TurnStartResponse = app_server
.request(|request_id| ClientRequest::TurnStart {
request_id,
@@ -676,13 +992,7 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
.requests()
.into_iter()
.find(|request| {
request
.inputs_of_type("agent_message")
.iter()
.any(|message| {
message.get("recipient").and_then(serde_json::Value::as_str)
== Some("/root/worker")
})
request.header("thread-id").as_deref() == Some(child_thread_id.as_str())
&& request.body_contains_text(FOLLOWUP_TASK)
})
{
@@ -693,18 +1003,21 @@ async fn cold_resume_preserves_effective_developer_instructions_for_roleless_wor
})
.await?;
let developer_texts = resumed_child_request.message_input_texts("developer");
assert!(
developer_texts
.iter()
.any(|text| text == expected_developer_instructions),
"resumed worker lost its effective developer instructions: {developer_texts:?}"
assert_eq!(
resumed_child_request.body_json()["reasoning"]["effort"],
json!(expected_reasoning_effort)
);
if agent_type.is_some() {
assert!(!has_shell_tool(&resumed_child_request));
}
assert_developer_instructions(&resumed_child_request, "resumed");
assert!(
developer_texts
.iter()
.all(|text| text != unexpected_developer_instructions),
"resumed worker received the wrong developer instructions: {developer_texts:?}"
redirect_server
.received_requests()
.await
.expect("redirect mock requests")
.is_empty(),
"caller or role config redirected the owner-restored child"
);
Ok(())
@@ -35,6 +35,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_features::Feature;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
@@ -1299,7 +1300,10 @@ async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result
async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
MockResponsesConfig::new(&server.uri())
.disable_feature(Feature::MultiAgentV2)
.enable_feature(Feature::Collab)
.write(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T09-00-00",
@@ -1309,33 +1313,46 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result
/*git_info*/ None,
)?;
let parent_thread_id = ThreadId::from_string(&cli_id)?;
let parent_rollout_path = rollout_path(codex_home.path(), "2025-02-01T09-00-00", &cli_id);
let mut parent_meta = read_session_meta_line(&parent_rollout_path).await?;
parent_meta.meta.multi_agent_version = Some(MultiAgentVersion::V2);
append_rollout_item_to_path(&parent_rollout_path, &RolloutItem::SessionMeta(parent_meta))
.await?;
// Legacy children resume before the root restores the V2 registry from persisted spawn edges.
let mut expected = vec![(cli_id.clone(), None, false)];
let mut threads_to_resume = vec![cli_id.clone()];
let mut threads_to_resume = vec![(cli_id.clone(), SessionSource::Cli, Some(true))];
for (filename_ts, timestamp, version, capability, should_resume) in [
(
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
MultiAgentVersion::V1,
Some(MultiAgentVersion::V1),
Some(true),
true,
),
(
"2025-02-01T10-30-00",
"2025-02-01T10:30:00Z",
None,
Some(true),
true,
),
(
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
MultiAgentVersion::V2,
Some(MultiAgentVersion::V2),
Some(false),
true,
),
(
"2025-02-01T12-00-00",
"2025-02-01T12:00:00Z",
MultiAgentVersion::V2,
Some(MultiAgentVersion::V2),
None,
false,
),
] {
let thread_id = create_fake_rollout_with_source(
let thread_id = create_fake_parented_rollout_with_source(
codex_home.path(),
filename_ts,
timestamp,
@@ -1349,27 +1366,41 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result
agent_nickname: None,
agent_role: None,
}),
parent_thread_id.into(),
parent_thread_id,
)?;
let path = rollout_path(codex_home.path(), filename_ts, &thread_id);
let mut session_meta = read_session_meta_line(&path).await?;
session_meta.meta.multi_agent_version = Some(version);
append_rollout_item_to_path(&path, &RolloutItem::SessionMeta(session_meta)).await?;
let source = SessionSource::from(session_meta.meta.source.clone());
if let Some(version) = version {
session_meta.meta.multi_agent_version = Some(version);
append_rollout_item_to_path(&path, &RolloutItem::SessionMeta(session_meta)).await?;
}
if should_resume {
threads_to_resume.push(thread_id.clone());
let resume = (thread_id.clone(), source, capability);
if version == Some(MultiAgentVersion::V2) {
threads_to_resume.push(resume);
} else {
threads_to_resume.insert(/*index*/ 0, resume);
}
}
expected.push((thread_id, capability, !should_resume));
}
let mut mcp = init_mcp(codex_home.path()).await?;
for thread_id in threads_to_resume {
for (thread_id, source, capability) in threads_to_resume {
let request_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id,
thread_id: thread_id.clone(),
..Default::default()
})
.await?;
let _: ThreadResumeResponse =
let ThreadResumeResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
(thread.id, thread.source, thread.can_accept_direct_input),
(thread_id, source, capability)
);
}
let response = list_threads(