mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add scenario coverage for worker context across idle eviction (#52062)
## What changed Add a scenario test that queues a message for an idle worker, evicts it, and resumes it with `followup_task`. Assert that the same worker is reloaded and that queuing mail and eviction do not start another model turn. Snapshot the complete parent and worker request histories and the resumed worker's mailbox to verify that earlier context and unread mail survive eviction in order. GitOrigin-RevId: 2d0f7c63a2221df9c3d7e378eecaf14b8441d2b9
This commit is contained in:
@@ -106,6 +106,9 @@ mod agent_message_board;
|
||||
#[path = "scenarios_mailbox_preemption_tests.rs"]
|
||||
mod mailbox_preemption;
|
||||
|
||||
#[path = "scenarios_agent_eviction_tests.rs"]
|
||||
mod agent_eviction;
|
||||
|
||||
#[path = "scenarios_partial_answers.rs"]
|
||||
mod partial_answers;
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Snapshots a worker's retained history and unread mail across idle eviction and follow-up.
|
||||
|
||||
use super::*;
|
||||
use codex_core::ThreadEvictionOutcome;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use core_test_support::ThreadIdle;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::submit_thread_settings;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn idle_eviction_preserves_worker_context_for_followup() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||
extensions.thread_lifecycle_contributor(Arc::new(ThreadIdle));
|
||||
let mut test = test_codex()
|
||||
.with_model("gpt-5.6-sol")
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_config(|config| {
|
||||
configure_scenario_catalog(config);
|
||||
config.features.enable(Feature::Collab).unwrap();
|
||||
config.features.enable(Feature::MultiAgentV2).unwrap();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2DynamicTools)
|
||||
.unwrap();
|
||||
config.multi_agent_v2.max_concurrent_threads_per_session = 2;
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
// Reuse the client-tool flow to hold each worker result until the parent is idle.
|
||||
// Otherwise completion mail can race the parent's next model request in the snapshot.
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
let root = test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
dynamic_tools: vec![DynamicToolSpec::Function(DynamicToolFunctionSpec {
|
||||
name: "client_echo".to_string(),
|
||||
description: "Echo the supplied message through the client.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object", "properties": {"message": {"type": "string"}},
|
||||
"required": ["message"], "additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
})],
|
||||
environments: Some(vec![test.executor_environment().request()]),
|
||||
..StartThreadOptions::new(test.config.clone())
|
||||
})
|
||||
.await?;
|
||||
test.codex = root.thread;
|
||||
test.session_configured = root.session_configured;
|
||||
let parent_id = test.session_configured.session_id.to_string();
|
||||
let mut created = test.thread_manager.subscribe_thread_created();
|
||||
let mut child_id = None;
|
||||
|
||||
for (tool, arguments, answer) in [
|
||||
(
|
||||
"spawn_agent",
|
||||
json!({
|
||||
"task_name": "worker", "fork_turns": "none",
|
||||
"message": "Check who owns the API migration."
|
||||
}),
|
||||
"Mira owns the API migration.",
|
||||
),
|
||||
(
|
||||
"send_message",
|
||||
json!({"target": "worker", "message": "Use the new API for the migration."}),
|
||||
"",
|
||||
),
|
||||
(
|
||||
"followup_task",
|
||||
json!({"target": "worker", "message": "Continue the migration with the queued note."}),
|
||||
"I will continue with Mira using the new API.",
|
||||
),
|
||||
] {
|
||||
let call_id = format!("eviction-{tool}");
|
||||
responses::mount_sse_once_match(
|
||||
&server,
|
||||
wiremock::matchers::header("thread-id", parent_id.clone()),
|
||||
sse(vec![
|
||||
ev_function_call_with_namespace(
|
||||
&call_id,
|
||||
"collaboration",
|
||||
tool,
|
||||
&arguments.to_string(),
|
||||
),
|
||||
ev_completed("parent-call"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let expected_parent = parent_id.clone();
|
||||
responses::mount_sse_once_match(
|
||||
&server,
|
||||
move |request: &wiremock::Request| {
|
||||
request
|
||||
.headers
|
||||
.get("thread-id")
|
||||
.is_some_and(|id| id == expected_parent.as_str())
|
||||
&& String::from_utf8_lossy(&request.body).contains(&call_id)
|
||||
},
|
||||
sse(vec![ev_completed("parent-done")]),
|
||||
)
|
||||
.await;
|
||||
if tool != "send_message" {
|
||||
let parent_id = parent_id.clone();
|
||||
responses::mount_sse_once_match(
|
||||
&server,
|
||||
move |request: &wiremock::Request| {
|
||||
request
|
||||
.headers
|
||||
.get("thread-id")
|
||||
.is_some_and(|id| id != parent_id.as_str())
|
||||
},
|
||||
sse(vec![
|
||||
responses::ev_function_call(
|
||||
&format!("worker-echo-{tool}"),
|
||||
"client_echo",
|
||||
&json!({"message": answer}).to_string(),
|
||||
),
|
||||
ev_completed("worker-echo"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
test.submit_text_turn(&format!("Run {tool} for the API migration."))
|
||||
.await?;
|
||||
ThreadIdle::wait(&test.codex).await;
|
||||
|
||||
if tool == "send_message" {
|
||||
let id = child_id.expect("spawned child");
|
||||
let child = test.thread_manager.get_thread(id).await?;
|
||||
// Flush submission dispatch so the note is queued before idle cleanup starts.
|
||||
submit_thread_settings(&child, ThreadSettingsOverrides::default()).await?;
|
||||
assert_eq!(
|
||||
test.thread_manager.try_evict_v2_thread(child).await?,
|
||||
ThreadEvictionOutcome::Evicted,
|
||||
);
|
||||
assert!(test.thread_manager.get_thread(id).await.is_err());
|
||||
assert_eq!(
|
||||
responses::received_responses_requests(&server)
|
||||
.await
|
||||
.iter()
|
||||
.filter(|request| request.header("thread-id").as_deref()
|
||||
!= Some(parent_id.as_str()))
|
||||
.count(),
|
||||
2,
|
||||
"queue-only mail and eviction must not start another model turn",
|
||||
);
|
||||
} else {
|
||||
let id = created.recv().await?;
|
||||
if let Some(child_id) = child_id {
|
||||
assert_eq!(id, child_id, "the follow-up must reload the same child");
|
||||
} else {
|
||||
child_id = Some(id);
|
||||
}
|
||||
let child = test.thread_manager.get_thread(id).await?;
|
||||
let call = wait_for_event_match(&child, |event| match event {
|
||||
EventMsg::DynamicToolCallRequest(call) => Some(call.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(call.call_id, format!("worker-echo-{tool}"));
|
||||
responses::mount_sse_once_match(
|
||||
&server,
|
||||
wiremock::matchers::header("thread-id", id.to_string()),
|
||||
sse(vec![
|
||||
ev_assistant_message("worker-result", answer),
|
||||
ev_completed("worker-done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
child
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: call.call_id,
|
||||
response: DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: answer.to_string(),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
ThreadIdle::wait(&child).await;
|
||||
submit_thread_settings(&test.codex, ThreadSettingsOverrides::default()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut requests = responses::received_responses_requests(&server).await;
|
||||
// Preserve each stream's complete history while removing parent/child scheduling order.
|
||||
requests
|
||||
.sort_by_key(|request| request.header("thread-id").as_deref() != Some(parent_id.as_str()));
|
||||
assert_eq!(requests.len(), 10);
|
||||
let history = context_snapshot::format_request_history_snapshot(
|
||||
"A parent delegates an API migration, queues a note while the worker is idle, and follows up after idle eviction. The same worker resumes with its earlier answer and the unread note. Complete model streams are grouped as parent, then worker.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
||||
);
|
||||
// The shared formatter currently labels agent_message items without rendering their content.
|
||||
// Include the resumed worker's complete mailbox so the retained text and order are reviewable.
|
||||
let mut mailbox = responses::strip_metadata_from_json(
|
||||
responses::strip_response_item_ids_from_json(serde_json::Value::Array(
|
||||
requests
|
||||
.last()
|
||||
.expect("worker continuation")
|
||||
.inputs_of_type("agent_message"),
|
||||
)),
|
||||
);
|
||||
mailbox.sort_all_objects();
|
||||
let mailbox = serde_json::to_string_pretty(&mailbox)?;
|
||||
insta::assert_snapshot!(
|
||||
"idle_eviction_followup",
|
||||
format!("{history}\n\nResumed worker mailbox:\n{mailbox}")
|
||||
);
|
||||
test.thread_manager
|
||||
.shutdown_all_threads_bounded(Duration::from_secs(5))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_agent_eviction_tests.rs
|
||||
assertion_line: 183
|
||||
expression: "format!(\"{history}\\n\\nResumed worker mailbox:\\n{mailbox}\")"
|
||||
---
|
||||
Scenario: A parent delegates an API migration, queues a note while the worker is idle, and follows up after idle eviction. The same worker resumes with its earlier answer and the unread note. Complete model streams are grouped as parent, then worker.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (turn) --
|
||||
00:additional_tools/developer (2; hash=122dc70cb8c7e8f0):
|
||||
- namespace/functions; hash=f859506569b16c70
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
|
||||
- function/followup_task
|
||||
- function/interrupt_agent
|
||||
- function/list_agents
|
||||
- function/send_message
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
01:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/developer:
|
||||
You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
|
||||
|
||||
At the start of your turn, you are the active agent.
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
|
||||
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
|
||||
Child agents can also spawn their own sub-agents.
|
||||
<OMITTED 16 LINES; ~234 TOKENS; hash=b2e94283a8c22327>
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
|
||||
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
|
||||
|
||||
There are 2 available concurrency slots, meaning that up to 2 agents can be active at once, including you.
|
||||
|
||||
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...uctions; when doing so, set `fork_turns` to `"none"`. [hash=18ccf7e93ecabab9]
|
||||
04:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
05:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
06:message/user:
|
||||
Run spawn_agent for the API migration.
|
||||
-- request 2 (turn) --
|
||||
07:function_call/collaboration.spawn_agent:{"fork_turns":"none","message":"Check who owns the API migration.","task_name":"worker"}
|
||||
08:function_call_output:{"task_name":"/root/worker"}
|
||||
-- request 3 (turn) --
|
||||
09:message/user:
|
||||
Run send_message for the API migration.
|
||||
-- request 4 (turn) --
|
||||
10:function_call/collaboration.send_message:{"message":"Use the new API for the migration.","target":"worker"}
|
||||
11:function_call_output:
|
||||
12:agent_message
|
||||
-- request 5 (turn) --
|
||||
13:message/user:
|
||||
Run followup_task for the API migration.
|
||||
-- request 6 (turn) --
|
||||
14:function_call/collaboration.followup_task:{"message":"Continue the migration with the queued note.","target":"worker"}
|
||||
15:function_call_output:
|
||||
|
||||
## Window 2 (after request 6: input diverged at item 03)
|
||||
-- request 7 (turn) --
|
||||
00:additional_tools/developer (2; hash=122dc70cb8c7e8f0):
|
||||
- namespace/functions; hash=f859506569b16c70
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
|
||||
- function/followup_task
|
||||
- function/interrupt_agent
|
||||
- function/list_agents
|
||||
- function/send_message
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
01:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/developer:
|
||||
You are an agent in a team of agents collaborating to complete a task.
|
||||
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All ag...nd capable, and have access to the same set of tools. [hash=876a93c54d3f61d6]
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an... `send_message` to pass a message to a running agent. [hash=824c12425c7626dc]
|
||||
Child agents can also spawn their own sub-agents.
|
||||
|
||||
When you provide a response in the final channel, that content is immediately delivered back to your parent agent.
|
||||
<OMITTED 15 LINES; ~225 TOKENS; hash=fa366829bdb214be>
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
|
||||
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
|
||||
|
||||
There are 2 available concurrency slots, meaning that up to 2 agents can be active at once, including you.
|
||||
|
||||
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...uctions; when doing so, set `fork_turns` to `"none"`. [hash=18ccf7e93ecabab9]
|
||||
04:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
05:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
06:agent_message
|
||||
-- request 8 (turn) --
|
||||
07:function_call/client_echo:{"message":"Mira owns the API migration."}
|
||||
08:function_call_output:Mira owns the API migration.
|
||||
-- request 9 (turn) --
|
||||
09:message/assistant:
|
||||
Mira owns the API migration.
|
||||
10:agent_message
|
||||
11:agent_message
|
||||
-- request 10 (turn) --
|
||||
12:function_call/client_echo:{"message":"I will continue with Mira using the new API."}
|
||||
13:function_call_output:I will continue with Mira using the new API.
|
||||
|
||||
Resumed worker mailbox:
|
||||
[
|
||||
{
|
||||
"author": "/root",
|
||||
"content": [
|
||||
{
|
||||
"text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n",
|
||||
"type": "input_text"
|
||||
},
|
||||
{
|
||||
"encrypted_content": "Check who owns the API migration.",
|
||||
"type": "encrypted_content"
|
||||
}
|
||||
],
|
||||
"recipient": "/root/worker",
|
||||
"type": "agent_message"
|
||||
},
|
||||
{
|
||||
"author": "/root",
|
||||
"content": [
|
||||
{
|
||||
"text": "Message Type: MESSAGE\nTask name: /root/worker\nSender: /root\nPayload:\n",
|
||||
"type": "input_text"
|
||||
},
|
||||
{
|
||||
"encrypted_content": "Use the new API for the migration.",
|
||||
"type": "encrypted_content"
|
||||
}
|
||||
],
|
||||
"recipient": "/root/worker",
|
||||
"type": "agent_message"
|
||||
},
|
||||
{
|
||||
"author": "/root",
|
||||
"content": [
|
||||
{
|
||||
"text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n",
|
||||
"type": "input_text"
|
||||
},
|
||||
{
|
||||
"encrypted_content": "Continue the migration with the queued note.",
|
||||
"type": "encrypted_content"
|
||||
}
|
||||
],
|
||||
"recipient": "/root/worker",
|
||||
"type": "agent_message"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user