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
2026-08-24 20:47:10 +00:00
committed by copyberry
parent 3a211471d2
commit d21794d6ba
15 changed files with 1074 additions and 143 deletions
+2
View File
@@ -363,6 +363,8 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. On cold resume, approval policy and the active permission-profile ID select a source in this order: request override, latest persisted thread setting, current configured default. The persisted profile ID is resolved through the same config and requirements path as a `permissions` override. Threads without an active profile ID use current config instead of restoring their concrete historical permissions.
Parent-owned Multi-Agent V2 children are an exception: `thread/resume` ignores configuration overrides and reattaches to the existing child. An unloaded child is reloaded through its actual, currently loaded parent using parent-derived configuration. If that owner-controlled reload cannot be performed, the request returns JSON-RPC error `-32600`; resume the parent first, or use `thread/read` or `thread/turns/list` to inspect the child's stored history without loading it. This policy follows the child's multi-agent runtime, including leaf workers whose models cannot delegate further.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. A cold paginated resume can still replay persisted `thread/tokenUsage/updated` when it can identify the corresponding stored turn; resuming an already-loaded thread waits for the next live update.
Paginated threads keep the same resume contract as legacy threads. A default resume materializes the full projected history into `thread.turns`; `excludeTurns: true` keeps that array empty and includes `turnsBackwardsCursor` and `itemsBackwardsCursor` for the durable history visible at the resume boundary. Pass each cursor directly to its matching list API with `sortDirection: "desc"`; the first page includes the row identified by the cursor, while newer records arrive through live notifications. Either cursor is `null` when there is no durable row yet.
@@ -661,9 +661,11 @@ pub(super) async fn handle_pending_thread_resume_request(
} else {
None
};
let token_usage_turn_id = pending
.include_turns
.then(|| restored_token_usage_turn_id(&pending.history_items, thread.turns.as_slice()));
let token_usage_turn_id = pending.cold_resume_token_usage_turn_id.or_else(|| {
pending
.include_turns
.then(|| restored_token_usage_turn_id(&pending.history_items, thread.turns.as_slice()))
});
if pending.initial_turns_page.is_none() {
initial_turns_page = None;
}
@@ -762,8 +764,8 @@ pub(super) async fn handle_pending_thread_resume_request(
outgoing
.send_response_with_thread_originator(request_id, response, originator)
.await;
// Match cold resume: metadata-only resume should attach the listener without
// paying the cost of turn reconstruction for historical usage replay.
// Warm metadata-only resumes skip history reconstruction. Cold paginated children can
// replay usage using attribution captured before the listener was attached.
if let Some(token_usage_turn_id) = token_usage_turn_id {
// Rejoining a loaded thread has the same UI contract as a cold resume, but
// uses the live conversation state instead of reconstructing a new session.
@@ -3538,6 +3538,7 @@ impl ThreadRequestProcessor {
&params,
app_server_client_name.clone(),
app_server_client_version.clone(),
/*cold_resume_history*/ None,
)
.await
{
@@ -3607,6 +3608,51 @@ impl ThreadRequestProcessor {
});
let paginated_resume = paginated_thread_id.is_some();
// Parent-owned V2 children must resume through their owner, not caller configuration.
if let InitialHistory::Resumed(resumed_history) = &thread_history
&& let Some((source, _)) = thread_history.get_resumed_session_sources()
&& !can_accept_direct_input(thread_history.get_multi_agent_version(), &source)
{
let child_thread_id = resumed_history.conversation_id;
self.thread_manager
.ensure_multi_agent_v2_child_loaded(child_thread_id)
.await
.map_err(|err| {
tracing::warn!(
thread_id = %child_thread_id,
error = %err,
"failed to resume a multi-agent v2 child through its parent"
);
invalid_request(
"cannot resume an unloaded multi-agent v2 sub-agent through its parent; resume the parent first, or use thread/read to inspect it",
)
})?;
let cold_resume_history = paginated_resume.then(|| thread_history.get_rollout_items());
// Attach to the resolved child with only the caller's history-paging preferences.
let attach_params = ThreadResumeParams {
thread_id: child_thread_id.to_string(),
exclude_turns,
initial_turns_page,
..Default::default()
};
return match self
.resume_running_thread(
&request_id,
&attach_params,
app_server_client_name,
app_server_client_version,
cold_resume_history,
)
.await?
{
RunningThreadResumeResult::Handled => Ok(()),
RunningThreadResumeResult::NotRunning(_) => Err(invalid_request(
"cannot resume an unloaded multi-agent v2 sub-agent through its parent; resume the parent first, or use thread/read to inspect it",
)),
};
}
let history_cwd = thread_history.session_cwd();
let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots);
let mut typesafe_overrides = self.build_thread_config_overrides(
@@ -3954,6 +4000,7 @@ impl ThreadRequestProcessor {
params: &ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
cold_resume_history: Option<&[RolloutItem]>,
) -> Result<RunningThreadResumeResult, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.thread_id)
@@ -4029,7 +4076,14 @@ impl ThreadRequestProcessor {
let is_running =
matches!(existing_thread.agent_status().await, AgentStatus::Running);
if !has_subscribers && matches!(loaded_status, ThreadStatus::Idle) && !is_running {
// Parent-owned V2 children must not be rebuilt from public resume overrides.
if can_accept_direct_input(
existing_thread.multi_agent_version(),
&config_snapshot.session_source,
) && !has_subscribers
&& matches!(loaded_status, ThreadStatus::Idle)
&& !is_running
{
// A loaded idle thread is only a cache entry. Shut it down
// before removing it so cold resume cannot duplicate a
// thread that timed out during shutdown.
@@ -4154,6 +4208,19 @@ impl ThreadRequestProcessor {
} else {
None
};
let cold_resume_token_usage_turn_id = cold_resume_history
.map(|history| {
let turns = paginated_turns
.as_deref()
.filter(|turns| !turns.is_empty())
.unwrap_or_else(|| {
paginated_initial_turns_page
.as_ref()
.map_or(&[][..], |page| page.data.as_slice())
});
restored_token_usage_turn_id(history, turns)
})
.filter(|turn_id| !turn_id.is_empty());
let paginated_initial_turns_page_with_active_slot = if paginated_resume {
match params.initial_turns_page.as_ref() {
Some(params)
@@ -4181,6 +4248,7 @@ impl ThreadRequestProcessor {
Box::new(crate::thread_state::PendingThreadResumeRequest {
request_id: request_id.clone(),
history_items,
cold_resume_token_usage_turn_id,
config_snapshot,
instruction_sources,
thread_summary,
+2
View File
@@ -36,6 +36,8 @@ type PendingInterruptQueue = Vec<ConnectionRequestId>;
pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) history_items: Vec<RolloutItem>,
/// Usage attribution already resolved while cold-loading a paginated child.
pub(crate) cold_resume_token_usage_turn_id: Option<String>,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<LegacyAppPathString>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
@@ -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(
@@ -114,7 +114,7 @@ async fn interrupted_v2_agent_is_lost_after_residency_eviction() {
mark_thread_completed(second.thread.as_ref()).await;
let err = control
.ensure_v2_agent_loaded(config, first.thread_id)
.ensure_v2_agent_loaded(config, first.thread_id, /*parent*/ None)
.await
.expect_err("evicted interrupted agent should stay lost");
match err.details() {
+189 -13
View File
@@ -1,6 +1,7 @@
use super::residency::is_v2_resident_session_source;
use super::*;
use crate::agent::role::apply_role_to_config;
use crate::codex_thread::CodexThread;
use crate::config::PermissionProfileSnapshot;
use crate::context::ContextualUserFragment;
use crate::context::CurrentTimeReminder;
@@ -9,9 +10,13 @@ use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::session::multi_agents::resolve_usage_hints;
use crate::tools::handlers::multi_agents_common::build_agent_resume_config;
use codex_context_fragments::set_annotated_content;
use codex_context_fragments::to_annotated_content;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::intersect_effective_permission_profiles;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_utils_path_uri::PathUri;
const AGENT_NAMES: &str = include_str!("../agent_names.txt");
@@ -267,20 +272,65 @@ impl AgentControl {
.await
}
fn validate_loaded_v2_child(
&self,
thread: &CodexThread,
parent_thread_id: ThreadId,
) -> CodexResult<()> {
if thread.is_running()
&& thread.multi_agent_version() == Some(MultiAgentVersion::V2)
&& thread.session_source.parent_thread_id() == Some(parent_thread_id)
&& Arc::ptr_eq(&self.state, &thread.session.services.agent_control.state)
{
return Ok(());
}
Err(CodexErr::InvalidRequest(format!(
"multi-agent v2 child {} is not owned by its loaded parent",
thread.session.thread_id
)))
}
/// A provided parent enables owner-validated reloads; `None` preserves sender-driven reloads.
pub(crate) async fn ensure_v2_agent_loaded(
&self,
mut config: Config,
thread_id: ThreadId,
parent: Option<Arc<CodexThread>>,
) -> CodexResult<()> {
let state = self.upgrade()?;
if state.get_thread(thread_id).await.is_ok() {
let parent = if let Some(parent) = parent {
let parent_thread_id = parent.session.thread_id;
let turn = parent.session.new_default_turn().await;
config = build_agent_resume_config(&turn).map_err(|_| {
CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id} with the current parent settings"
))
})?;
let registered_parent = state.get_thread(parent_thread_id).await.ok();
if !registered_parent
.as_ref()
.is_some_and(|registered| Arc::ptr_eq(registered, &parent))
|| !parent.is_running()
|| parent.multi_agent_version() != Some(MultiAgentVersion::V2)
|| !Arc::ptr_eq(&self.state, &parent.session.services.agent_control.state)
{
return Err(CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: parent ownership is unavailable; resume the parent first"
)));
}
Some((parent, turn.environments.clone()))
} else {
None
};
let owner_thread_id = parent.as_ref().map(|(parent, _)| parent.session.thread_id);
if owner_thread_id.is_none() && state.get_thread(thread_id).await.is_ok() {
self.touch_loaded_v2_residency(&state, thread_id).await;
return Ok(());
}
if self.state.agent_metadata_for_thread(thread_id).is_none() {
return Err(CodexErr::ThreadNotFound(thread_id));
}
let environment_selections = self.state.evicted_environments(thread_id);
let mut environment_selections = self.state.evicted_environments(thread_id);
let stored_thread = state
.read_stored_thread(ReadThreadParams {
@@ -291,6 +341,7 @@ impl AgentControl {
.await?;
let stored_model = stored_thread.model.clone();
let stored_model_provider = stored_thread.model_provider.clone();
let stored_reasoning_effort = stored_thread.reasoning_effort.clone();
let stored_source = stored_thread.source.clone();
let stored_parent_thread_id = stored_thread.parent_thread_id;
let history = load_agent_model_context(&state, thread_id, stored_thread.history_mode)
@@ -307,6 +358,25 @@ impl AgentControl {
let (session_source, _) = initial_history
.get_resumed_session_sources()
.unwrap_or((stored_source, None));
if let Some(parent_thread_id) = owner_thread_id {
if session_source.parent_thread_id() != Some(parent_thread_id)
|| initial_history
.get_resumed_parent_thread_id()
.is_some_and(|recorded_parent| recorded_parent != parent_thread_id)
|| stored_parent_thread_id
.is_some_and(|recorded_parent| recorded_parent != parent_thread_id)
{
return Err(CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: recorded parent ownership is inconsistent"
)));
}
if let Ok(thread) = state.get_thread(thread_id).await {
self.validate_loaded_v2_child(&thread, parent_thread_id)?;
self.touch_loaded_v2_residency(&state, thread_id).await;
return Ok(());
}
}
config.model_reasoning_effort = stored_reasoning_effort;
if let Some(role_name) = session_source.get_agent_role() {
let runtime_approval_policy = config.permissions.approval_policy.value();
let runtime_approvals_reviewer = config.approvals_reviewer;
@@ -358,20 +428,118 @@ impl AgentControl {
})?;
config.model_provider_id = stored_model_provider;
}
let parent_thread_id = owner_thread_id
.or_else(|| initial_history.get_resumed_parent_thread_id())
.or(stored_parent_thread_id);
let (inherited_environments, inherited_exec_policy, client_mcp_extensions) = if let Some(
(parent, parent_environments),
) =
parent.as_ref()
{
let parent_config = parent.session.get_config().await;
if !crate::exec_policy::child_uses_parent_exec_policy(&parent_config, &config) {
return Err(CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: parent execution policy has changed; retry through the parent"
)));
}
if let Some(selections) = environment_selections.as_mut() {
for selection in selections {
let environment_id = &selection.environment_id;
let invalid_environment = |reason: &str| {
CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: cached environment {environment_id} {reason}"
))
};
// Matching the attachment also keeps startup on the captured owner executor.
let owner_environment = parent_environments
.turn_environments()
.find(|environment| {
environment.selection.environment_id == *environment_id
&& environment.cwd() == &selection.cwd
&& environment.workspace_roots()
== selection.workspace_roots.as_slice()
})
.ok_or_else(|| {
invalid_environment("no longer matches a ready parent environment")
})?;
let owner_config = owner_environment.config();
let child_config = match &selection.config {
EnvironmentConfigState::FromThread => {
// Pin current owner authority instead of re-inferring child settings.
selection.config = EnvironmentConfigState::Ready(owner_config.clone());
continue;
}
EnvironmentConfigState::Ready(config) => config,
EnvironmentConfigState::Pending | EnvironmentConfigState::Failed(_) => {
return Err(invalid_environment("configuration is not ready"));
}
};
let mut bounded_config = child_config.clone();
bounded_config.permission_profile = owner_config.permission_profile.clone();
if bounded_config != *owner_config {
return Err(invalid_environment(
"configuration differs from the current parent",
));
}
if child_config.permission_profile == owner_config.permission_profile {
continue;
}
if owner_environment.environment.is_remote() {
return Err(invalid_environment(
"permissions changed on a remote executor",
));
}
let cwd = selection.cwd.to_abs_path().map_err(|_| {
invalid_environment("working directory is not a local absolute path")
})?;
let roots = selection
.workspace_roots
.iter()
.map(PathUri::to_abs_path)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
invalid_environment("workspace roots are not local absolute paths")
})?;
let authority = owner_environment
.permission_profile()
.clone()
.materialize_project_roots_with_workspace_roots(&roots);
let requested = child_config
.permission_profile
.permission_profile()
.clone()
.materialize_project_roots_with_workspace_roots(&roots);
let permissions =
intersect_effective_permission_profiles(&authority, &requested, &cwd)
.map_err(|err| {
invalid_environment(&format!(
"permissions cannot be intersected safely: {err}"
))
})?;
bounded_config.permission_profile =
PermissionProfileSnapshot::legacy(permissions);
selection.config = EnvironmentConfigState::Ready(bounded_config);
}
}
(
Some(parent_environments.clone()),
Some(Arc::clone(&parent.session.services.exec_policy)),
Some(parent.client_mcp_extensions()),
)
} else {
(
self.inherited_environments_for_source(&state, Some(&session_source))
.await,
self.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
.await,
None,
)
};
// Reserving a slot can evict an idle nested parent. Keep its authority captured above.
let residency_slot = self
.reserve_v2_residency_slot(&state, &config, Some(thread_id))
.await?;
let parent_thread_id = initial_history
.get_resumed_parent_thread_id()
.or(stored_parent_thread_id);
let inherited_environments = self
.inherited_environments_for_source(&state, Some(&session_source))
.await;
let inherited_exec_policy = self
.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
.await;
match state
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
config,
@@ -382,17 +550,24 @@ impl AgentControl {
environment_selections,
inherited_environments,
inherited_exec_policy,
client_mcp_extensions,
})
.await
{
Ok(reloaded_thread) => {
if let Some(parent_thread_id) = owner_thread_id {
self.validate_loaded_v2_child(&reloaded_thread.thread, parent_thread_id)?;
}
self.state.clear_evicted_environments(thread_id);
residency_slot.commit(reloaded_thread.thread_id);
state.notify_thread_created(reloaded_thread.thread_id);
Ok(())
}
Err(err) => {
if state.get_thread(thread_id).await.is_ok() {
if let Ok(thread) = state.get_thread(thread_id).await {
if let Some(parent_thread_id) = owner_thread_id {
self.validate_loaded_v2_child(&thread, parent_thread_id)?;
}
self.state.clear_evicted_environments(thread_id);
drop(residency_slot);
self.touch_loaded_v2_residency(&state, thread_id).await;
@@ -1042,6 +1217,7 @@ impl AgentControl {
environment_selections: None,
inherited_environments,
inherited_exec_policy,
client_mcp_extensions: None,
})
.await?;
let mut agent_metadata = agent_metadata;
+149 -28
View File
@@ -3,6 +3,7 @@ use crate::CodexThread;
use crate::StateDbHandle;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::agent::next_thread_spawn_depth;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::config::AgentRoleConfig;
@@ -14,6 +15,7 @@ use crate::context::MultiAgentRoleInstructions;
use crate::context::SubagentNotification;
use crate::init_state_db;
use crate::thread_manager::StartThreadOptions;
use crate::tools::handlers::multi_agents_common::thread_spawn_source;
use assert_matches::assert_matches;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::empty_extension_registry;
@@ -34,6 +36,8 @@ use codex_protocol::config_types::Settings;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -42,6 +46,7 @@ use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
@@ -706,34 +711,99 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
#[tokio::test]
async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
config.model = Some("gpt-5.6-sol".to_string());
let harness = AgentControlHarness::new_with_config(home, config).await;
let (parent_thread_id, _parent_thread) = harness.start_paginated_thread().await;
let agent_path = AgentPath::try_from("/root/worker").expect("agent path");
let mut child_config = harness.config.clone();
child_config.model = Some("gpt-5.6-luna".to_string());
let spawned_agent = harness
.control
check_v2_agent_reload(V2ReloadRoute::Sender).await;
}
#[tokio::test]
async fn ensure_v2_child_loaded_preserves_evicted_parent_authority() {
check_v2_agent_reload(V2ReloadRoute::NestedParent).await;
}
#[derive(Clone, Copy)]
enum V2ReloadRoute {
Sender,
NestedParent,
}
async fn spawn_v2_reload_test_child(
control: &AgentControl,
config: Config,
parent: &CodexThread,
task_name: &str,
) -> LiveAgent {
let source = thread_spawn_source(
parent.session.thread_id,
&parent.session_source,
next_thread_spawn_depth(&parent.session_source),
/*agent_role*/ None,
Some(task_name.to_string()),
)
.expect("child source");
control
.spawn_agent_with_metadata(
child_config,
config,
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(agent_path.clone()),
agent_nickname: None,
agent_role: None,
})),
Some(source),
SpawnAgentOptions {
parent_thread_id: Some(parent_thread_id),
parent_thread_id: Some(parent.session.thread_id),
..Default::default()
},
)
.await
.expect("spawn_agent should succeed");
.expect("spawn_agent should succeed")
}
async fn check_v2_agent_reload(route: V2ReloadRoute) {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
config.model = Some("gpt-5.6-sol".to_string());
config.multi_agent_v2.max_concurrent_threads_per_session = 3;
config.permissions.allow_login_shell = true;
config
.permissions
.set_permission_profile(PermissionProfile::read_only())
.expect("read-only parent profile");
let harness = AgentControlHarness::new_with_config(home, config).await;
let client_mcp_extensions =
ClientMcpExtensions::new([(OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))]);
let root = harness
.manager
.start_thread(StartThreadOptions {
history_mode: Some(ThreadHistoryMode::Paginated),
client_mcp_extensions: client_mcp_extensions.clone(),
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("start root thread");
let control = root.thread.session.services.agent_control.clone();
let parent_thread = match route {
V2ReloadRoute::Sender => root.thread,
V2ReloadRoute::NestedParent => {
let parent = spawn_v2_reload_test_child(
&control,
harness.config.clone(),
&root.thread,
"parent",
)
.await;
harness
.manager
.get_thread(parent.thread_id)
.await
.expect("nested parent should exist")
}
};
let parent_thread_id = parent_thread.session.thread_id;
let mut child_config = harness.config.clone();
child_config.model = Some("gpt-5.6-luna".to_string());
let spawned_agent =
spawn_v2_reload_test_child(&control, child_config, &parent_thread, "worker").await;
let agent_path = spawned_agent
.metadata
.agent_path
.clone()
.expect("agent path");
let child_thread = harness
.manager
.get_thread(spawned_agent.thread_id)
@@ -781,16 +851,68 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
.cloned()
.expect("ollama provider should be configured");
harness
.control
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id)
.await
.expect("known v2 agent should reload");
let mut parent_turn = parent_thread.session.new_default_turn().await;
match route {
V2ReloadRoute::Sender => control
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id, /*parent*/ None)
.await
.expect("known v2 agent should reload"),
V2ReloadRoute::NestedParent => {
let environment = parent_turn
.environments
.primary()
.expect("parent environment");
let thread_config = environment.config().clone();
let mut owner_config = thread_config.clone();
owner_config.allow_login_shell = false;
let mut selection = environment.selection();
selection.config = EnvironmentConfigState::Ready(owner_config);
parent_thread
.session
.services
.turn_environments
.update_selections(std::slice::from_ref(&selection), &thread_config);
parent_turn = parent_thread.session.new_default_turn().await;
parent_thread.session.mark_interrupted();
// The fixture has no task runner to finish the turn or consume child results.
*parent_thread.session.active_turn.lock().await = None;
let _ = parent_thread
.session
.input_queue
.drain_mailbox_input_items()
.await;
harness
.manager
.ensure_multi_agent_v2_child_loaded(spawned_agent.thread_id)
.await
.expect("known child should reload through its parent");
assert!(harness.manager.get_thread(parent_thread_id).await.is_err());
}
}
let reloaded_child = harness
.manager
.get_thread(spawned_agent.thread_id)
.await
.expect("reloaded child thread should exist");
if matches!(route, V2ReloadRoute::NestedParent) {
let reloaded_turn = reloaded_child.session.new_default_turn().await;
assert_eq!(
(
reloaded_turn.environments.to_selections(),
reloaded_turn.permission_profile(),
reloaded_child.client_mcp_extensions(),
),
(
parent_turn.environments.to_selections(),
parent_turn.permission_profile(),
client_mcp_extensions,
),
);
assert!(Arc::ptr_eq(
&reloaded_child.session.services.exec_policy,
&parent_thread.session.services.exec_policy,
));
}
assert_eq!(
reloaded_child.config_snapshot().await.model,
"gpt-5.6-luna",
@@ -821,8 +943,7 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
"hello after reload".to_string(),
/*trigger_turn*/ false,
);
harness
.control
control
.send_inter_agent_communication(
spawned_agent.thread_id,
communication.clone(),
+39 -1
View File
@@ -328,6 +328,7 @@ pub(crate) struct ResumeThreadWithHistoryOptions {
pub(crate) environment_selections: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) inherited_environments: Option<TurnEnvironmentSnapshot>,
pub(crate) inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
pub(crate) client_mcp_extensions: Option<ClientMcpExtensions>,
}
/// Shared, `Arc`-owned state for [`ThreadManager`]. This `Arc` is required to have a single
@@ -1012,6 +1013,39 @@ impl ThreadManager {
.await
}
/// Reloads a recorded Multi-Agent V2 child through its currently loaded immediate parent.
///
/// The child keeps the existing parent-controlled reload semantics. Callers cannot supply
/// configuration overrides, and an unavailable or unrecognized owner is an error.
pub async fn ensure_multi_agent_v2_child_loaded(
&self,
child_thread_id: ThreadId,
) -> CodexResult<()> {
let stored_thread = self
.state
.read_stored_thread(ReadThreadParams {
thread_id: child_thread_id,
include_archived: true,
include_history: false,
})
.await?;
let Some(parent_thread_id) = stored_thread.parent_thread_id else {
return Err(CodexErr::InvalidRequest(format!(
"thread {child_thread_id} is not a recorded multi-agent v2 child"
)));
};
let parent = self.get_thread(parent_thread_id).await.map_err(|_| {
CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {child_thread_id}: parent {parent_thread_id} is not loaded; resume the parent first"
))
})?;
let config = parent.session.get_config().await.as_ref().clone();
let agent_control = parent.session.services.agent_control.clone();
agent_control
.ensure_v2_agent_loaded(config, child_thread_id, Some(parent))
.await
}
#[instrument(level = "trace", skip_all)]
pub async fn resume_thread_with_history(
&self,
@@ -1708,8 +1742,12 @@ impl ThreadManagerState {
environment_selections,
inherited_environments,
inherited_exec_policy,
client_mcp_extensions,
} = options;
let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await;
let client_mcp_extensions = match client_mcp_extensions {
Some(client_mcp_extensions) => client_mcp_extensions,
None => self.client_mcp_extensions_for_child(parent_thread_id).await,
};
let thread_source = initial_history.get_resumed_thread_source();
let environments = environment_selections.or_else(|| {
inherited_environments
@@ -52,7 +52,7 @@ impl Handler {
session
.services
.agent_control
.ensure_v2_agent_loaded(resume_config, receiver_thread_id)
.ensure_v2_agent_loaded(resume_config, receiver_thread_id, /*parent*/ None)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
@@ -86,7 +86,7 @@ pub(crate) async fn handle_message_string_tool(
session
.services
.agent_control
.ensure_v2_agent_loaded(resume_config, receiver_thread_id)
.ensure_v2_agent_loaded(resume_config, receiver_thread_id, /*parent*/ None)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
let author = turn
@@ -150,11 +150,20 @@ async fn handle_spawn_agent(
.await?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
// Remember an applied configured default so cold reload reapplies its restrictions.
let persisted_role_name = role_name.or_else(|| {
(!is_full_history_fork
&& config
.agent_roles
.get(DEFAULT_ROLE_NAME)
.is_some_and(|role| role.config_file.is_some()))
.then_some(DEFAULT_ROLE_NAME)
});
let spawn_source = thread_spawn_source(
session.thread_id,
&turn.session_source,
child_depth,
role_name,
persisted_role_name,
Some(args.task_name.clone()),
)?;
let new_agent_path = spawn_source.get_agent_path().ok_or_else(|| {
+155 -19
View File
@@ -1,10 +1,13 @@
use anyhow::Result;
use codex_exec_server::CreateDirectoryOptions;
use codex_features::Feature;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::PermissionProfileSnapshot;
use codex_protocol::protocol::EnvironmentConfig;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TurnEnvironmentSelections;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -13,11 +16,13 @@ use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::submit_thread_settings;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use test_case::test_case;
const FIRST_PROMPT: &str = "spawn the first worker";
const FIRST_TASK: &str = "first worker task";
@@ -198,8 +203,22 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()>
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ResidencyReload {
Sender,
OwnerNarrowsPermissions,
OwnerPreservesStricterChild,
OwnerRevokesWorkspaceRoot,
}
#[test_case(ResidencyReload::Sender; "sender preserves stricter child")]
#[test_case(ResidencyReload::OwnerNarrowsPermissions; "owner narrows cached permissions")]
#[test_case(ResidencyReload::OwnerPreservesStricterChild; "owner preserves stricter child")]
#[test_case(ResidencyReload::OwnerRevokesWorkspaceRoot; "owner revokes cached workspace root")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Result<()> {
async fn v2_residency_reload_preserves_inherited_environment_and_tools(
reload: ResidencyReload,
) -> Result<()> {
const EVICT_PROMPT: &str = "spawn the replacement worker";
const FOLLOWUP_PROMPT: &str = "continue the original worker";
const FOLLOWUP_TASK: &str = "continue work in the original environment";
@@ -254,15 +273,51 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu
});
let test = builder.build_with_remote_and_local_env(&server).await?;
let mut child_environment = test.executor_environment().selection().clone();
child_environment.config = EnvironmentConfigState::Ready(EnvironmentConfig {
allow_login_shell: test.config.permissions.allow_login_shell,
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()),
shell_environment_policy: Default::default(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
});
let (child_permissions, parent_permissions) = match reload {
ResidencyReload::Sender => (
PermissionProfile::read_only(),
PermissionProfile::workspace_write(),
),
ResidencyReload::OwnerNarrowsPermissions => {
(PermissionProfile::Disabled, PermissionProfile::read_only())
}
ResidencyReload::OwnerPreservesStricterChild => {
(PermissionProfile::read_only(), PermissionProfile::Disabled)
}
ResidencyReload::OwnerRevokesWorkspaceRoot => (
PermissionProfile::workspace_write(),
PermissionProfile::workspace_write(),
),
};
if reload == ResidencyReload::OwnerRevokesWorkspaceRoot {
child_environment.cwd = test.workspace_path_uri("retained")?;
child_environment.workspace_roots = vec![
child_environment.cwd.clone(),
test.workspace_path_uri("revoked")?,
];
for root in &child_environment.workspace_roots {
test.fs()
.create_directory(
root,
CreateDirectoryOptions {
recursive: true,
follow_symlinks: true,
},
/*sandbox*/ None,
)
.await?;
}
} else {
child_environment.config = EnvironmentConfigState::Ready(EnvironmentConfig {
allow_login_shell: test.config.permissions.allow_login_shell,
permission_profile: PermissionProfileSnapshot::legacy(child_permissions),
shell_environment_policy: Default::default(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
});
}
if let Some(exec_server_url) = test.executor_environment().exec_server_url() {
test.thread_manager
.environment_manager()
@@ -274,8 +329,18 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu
}
let mut created_threads = test.thread_manager.subscribe_thread_created();
test.submit_turn_with_environments(FIRST_PROMPT, Some(vec![child_environment.clone()]))
.await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
environments: Some(TurnEnvironmentSelections::new(
test.config.cwd.clone(),
vec![child_environment.clone()],
)),
..Default::default()
},
)
.await?;
test.submit_text_turn(FIRST_PROMPT).await?;
let first_thread_id = created_threads.recv().await?;
let first_thread = test.thread_manager.get_thread(first_thread_id).await?;
wait_for_event(first_thread.as_ref(), |event| {
@@ -283,14 +348,29 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu
})
.await;
let mut sender_environment = child_environment.clone();
let EnvironmentConfigState::Ready(sender_config) = &mut sender_environment.config else {
unreachable!("child environment config should be ready");
};
sender_config.permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::workspace_write());
test.submit_turn_with_environments(EVICT_PROMPT, Some(vec![sender_environment]))
let mut parent_environment = child_environment.clone();
if reload == ResidencyReload::OwnerRevokesWorkspaceRoot {
parent_environment.workspace_roots.truncate(1);
} else {
let EnvironmentConfigState::Ready(parent_config) = &mut parent_environment.config else {
unreachable!("child environment config should be ready");
};
parent_config.permission_profile = PermissionProfileSnapshot::legacy(parent_permissions);
}
if reload == ResidencyReload::Sender {
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
environments: Some(TurnEnvironmentSelections::new(
test.config.cwd.clone(),
vec![parent_environment.clone()],
)),
..Default::default()
},
)
.await?;
}
test.submit_text_turn(EVICT_PROMPT).await?;
let replacement_thread_id = created_threads.recv().await?;
let replacement_thread = test
.thread_manager
@@ -307,6 +387,62 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu
.is_err()
);
if reload != ResidencyReload::Sender {
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
environments: Some(TurnEnvironmentSelections::new(
test.config.cwd.clone(),
vec![parent_environment.clone()],
)),
..Default::default()
},
)
.await?;
assert_eq!(
test.codex.config_snapshot().await.environments.environments,
vec![parent_environment]
);
let result = test
.thread_manager
.ensure_multi_agent_v2_child_loaded(first_thread_id)
.await;
let expected_error = match reload {
ResidencyReload::OwnerRevokesWorkspaceRoot => {
Some("no longer matches a ready parent environment")
}
ResidencyReload::OwnerNarrowsPermissions
| ResidencyReload::OwnerPreservesStricterChild
if test.executor_environment().environment().is_remote() =>
{
Some("permissions changed on a remote executor")
}
ResidencyReload::Sender
| ResidencyReload::OwnerNarrowsPermissions
| ResidencyReload::OwnerPreservesStricterChild => None,
};
if let Some(expected_error) = expected_error {
let error = result.expect_err("reload must reject stale owner authority");
assert!(
error.to_string().contains(expected_error),
"unexpected reload error: {error}"
);
assert!(
test.thread_manager
.get_thread(first_thread_id)
.await
.is_err()
);
return Ok(());
}
result?;
let EnvironmentConfigState::Ready(child_config) = &mut child_environment.config else {
unreachable!("successfully reloaded child environment config should be ready");
};
child_config.permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
}
test.submit_text_turn(FOLLOWUP_PROMPT).await?;
let reloaded_worker = test.thread_manager.get_thread(first_thread_id).await?;
wait_for_event(reloaded_worker.as_ref(), |event| {
+44 -11
View File
@@ -2189,15 +2189,7 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
runtime.block_on(async {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let root = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let root_thread_id = root.session.thread_id;
app.enqueue_primary_thread_session(root.session, root.turns)
.await?;
let root_thread_id = ThreadId::new();
let rollout_dir = app
.config
.codex_home
@@ -2206,6 +2198,29 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
.join("01")
.join("01");
std::fs::create_dir_all(&rollout_dir)?;
let root_session_meta = SessionMeta {
session_id: root_thread_id.into(),
id: root_thread_id,
timestamp: "2026-01-01T00:00:00Z".to_string(),
cwd: app.config.cwd.to_path_buf(),
originator: "codex-tui-test".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::Cli,
model_provider: Some(app.config.model_provider_id.clone()),
multi_agent_version: Some(MultiAgentVersion::V2),
..SessionMeta::default()
};
let root_session_meta_line = serde_json::json!({
"timestamp": "2026-01-01T00:00:00Z",
"type": "session_meta",
"payload": serde_json::to_value(root_session_meta)?,
});
std::fs::write(
rollout_dir.join(format!(
"rollout-2026-01-01T00-00-00-{root_thread_id}.jsonl"
)),
format!("{root_session_meta_line}\n"),
)?;
let mut child_thread_ids = Vec::new();
for (index, multi_agent_version) in [MultiAgentVersion::V1, MultiAgentVersion::V2]
.into_iter()
@@ -2214,7 +2229,7 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
let child_thread_id = ThreadId::new();
let timestamp = format!("2026-01-01T00:00:0{index}Z");
let session_meta = SessionMeta {
session_id: child_thread_id.into(),
session_id: root_thread_id.into(),
id: child_thread_id,
parent_thread_id: Some(root_thread_id),
timestamp: timestamp.clone(),
@@ -2241,7 +2256,26 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
"payload": serde_json::to_value(session_meta)?,
});
std::fs::write(rollout_path, format!("{session_meta_line}\n"))?;
child_thread_ids.push(child_thread_id);
}
// Cold-resume the persisted V2 root so its children are registered before selection.
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let root = app_server
.resume_thread(
app.config.clone(),
root_thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
app.enqueue_primary_thread_session(root.session, root.turns)
.await?;
for (child_thread_id, multi_agent_version) in child_thread_ids
.iter()
.copied()
.zip([MultiAgentVersion::V1, MultiAgentVersion::V2])
{
assert!(
app.attach_live_thread_for_selection(&mut app_server, child_thread_id)
.await?
@@ -2250,7 +2284,6 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
app.agent_navigation.is_parent_owned(child_thread_id),
multi_agent_version == MultiAgentVersion::V2
);
child_thread_ids.push(child_thread_id);
}
app.agent_navigation