Track multi-agent usage hints in world state (#37189)

## Why

Resumed sessions need the current multi-agent usage instructions when configuration changes or the saved history predates usage-hint tracking.

## What changed

- Store multi-agent usage hints in world state and refresh changed or previously untracked hints before re-emitting the active multi-agent mode.
- Present `wait_agent` polling guidance in the overridable developer instructions only when the tool is enabled, instead of embedding it in the tool schema.

## Testing

- Cover cold resumes, changes to `wait_agent` availability, custom usage hints, message ordering, and tool-schema behavior.

GitOrigin-RevId: a9c6798d5dc2dedc8dadc49e0a80ff50a757ab83
This commit is contained in:
Adam Perry @ OpenAI
2026-08-06 00:22:06 +00:00
committed by copyberry
parent 98da2c4499
commit 92b83e226d
13 changed files with 561 additions and 25 deletions
+3 -2
View File
@@ -11029,8 +11029,9 @@ max_concurrent_threads_per_session = 17
let config = resolve_multi_agent_v2_config(&config_toml);
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
let expected_suffix =
format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}");
let expected_suffix = format!(
"{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n{concurrency_guidance}"
);
assert!(
[
config.root_agent_usage_hint_text,
+29 -4
View File
@@ -255,6 +255,8 @@ You may also see them addressed as to=/root/..., which indicates your identity i
"#;
const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string.";
const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration";
const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str =
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message.
All agents share the same directory. In detail:
@@ -262,9 +264,17 @@ All agents share the same directory. In detail:
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
"#;
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
fn default_multi_agent_v2_usage_hint_text(
usage_hint_text: &str,
max_concurrency: usize,
wait_agent_usage_hint_text: Option<&str>,
) -> String {
let wait_agent_usage_hint_text = match wait_agent_usage_hint_text {
Some(wait_agent_usage_hint_text) => format!("{wait_agent_usage_hint_text}\n\n"),
None => String::new(),
};
format!(
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_usage_hint_text}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
)
}
@@ -1286,10 +1296,12 @@ impl MultiAgentV2Config {
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT),
)),
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT),
)),
subagent_developer_instructions: None,
multi_agent_mode_hint_text: None,
@@ -2726,8 +2738,21 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
let wait_agent_enabled = base
.and_then(|config| config.wait_agent_enabled)
.unwrap_or(default.wait_agent_enabled);
let mut default_root_agent_usage_hint_text = default.root_agent_usage_hint_text;
let mut default_subagent_usage_hint_text = default.subagent_usage_hint_text;
let default_wait_agent_usage_hint_text = if wait_agent_enabled {
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT)
} else {
None
};
let mut default_root_agent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
default_wait_agent_usage_hint_text,
));
let mut default_subagent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
default_wait_agent_usage_hint_text,
));
if expose_spawn_agent_model_overrides {
default_root_agent_usage_hint_text = Some(append_usage_hint_text(
default_root_agent_usage_hint_text.as_deref(),
+2
View File
@@ -18,6 +18,7 @@ mod legacy_model_mismatch_warning;
mod legacy_unified_exec_process_limit_warning;
mod model_switch_instructions;
mod multi_agent_mode_instructions;
mod multi_agent_usage_hint;
mod network_rule_saved;
mod permissions_instructions;
mod personality_spec_instructions;
@@ -62,6 +63,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm
pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning;
pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning;
pub(crate) use model_switch_instructions::ModelSwitchInstructions;
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHint;
pub(crate) use network_rule_saved::NetworkRuleSaved;
pub use permissions_instructions::ApprovalPromptContext;
pub use permissions_instructions::PermissionsInstructions;
@@ -0,0 +1,37 @@
use super::ContextualUserFragment;
/// Configured multi-agent instructions emitted as a standalone developer message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MultiAgentUsageHint {
text: String,
}
impl MultiAgentUsageHint {
pub(crate) fn new(text: &str) -> Self {
Self {
text: text.to_string(),
}
}
}
impl ContextualUserFragment for MultiAgentUsageHint {
fn role(&self) -> &'static str {
"developer"
}
fn requires_separate_message(&self) -> bool {
true
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
self.text.clone()
}
}
+8 -1
View File
@@ -7,6 +7,7 @@ mod environment;
mod environments_instructions;
mod model;
mod multi_agent_mode;
mod multi_agent_usage_hint;
mod permissions;
mod personality;
mod plugins_instructions;
@@ -40,6 +41,7 @@ pub(crate) use environment::EnvironmentsState;
pub(crate) use environments_instructions::EnvironmentsInstructionsState;
pub(crate) use model::ModelInstructionsState;
pub(crate) use multi_agent_mode::MultiAgentModeState;
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState;
pub(crate) use permissions::PermissionsState;
pub(crate) use personality::PersonalityState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
@@ -89,7 +91,7 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
}
fn matches_legacy_fragment(&self, role: &str, text: &str) -> bool {
S::matches_legacy_fragment(role, text)
WorldStateSection::matches_current_legacy_fragment(self, role, text)
}
fn has_retained_fragment_matcher(&self) -> bool {
@@ -219,6 +221,11 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
false
}
/// Recognizes legacy fragments whose identity depends on this section's current value.
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
/// Whether retained history must still contain this section's rendered fragment.
fn has_retained_fragment_matcher() -> bool {
false
@@ -1,5 +1,7 @@
use super::PreviousSectionState;
use super::WorldStateHash;
use super::WorldStateSection;
use super::multi_agent_usage_hint::MultiAgentUsageHintState;
use crate::context::ContextualUserFragment;
use crate::context::multi_agent_mode_instructions::MultiAgentModeInstructions;
use codex_protocol::config_types::MultiAgentMode;
@@ -14,6 +16,8 @@ const MULTI_AGENT_MODE_MAX_TOKENS: usize = 400;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub(crate) struct MultiAgentModeState {
mode: Option<MultiAgentMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage_hint_hash: Option<WorldStateHash>,
}
impl MultiAgentModeState {
@@ -26,8 +30,14 @@ impl MultiAgentModeState {
)),
mode @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive) => mode,
}),
usage_hint_hash: None,
}
}
pub(crate) fn with_usage_hint(mut self, usage_hint: &MultiAgentUsageHintState) -> Self {
self.usage_hint_hash = Some(usage_hint.snapshot());
self
}
}
impl WorldStateSection for MultiAgentModeState {
@@ -56,7 +66,8 @@ impl WorldStateSection for MultiAgentModeState {
) -> Option<Box<dyn ContextualUserFragment>> {
let mode = match (&self.mode, previous) {
(Some(mode), PreviousSectionState::Known(previous))
if previous.mode.as_ref() == Some(mode) =>
if previous.mode.as_ref() == Some(mode)
&& previous.usage_hint_hash == self.usage_hint_hash =>
{
return None;
}
@@ -65,6 +65,26 @@ fn persisted_mode_is_restored_only_when_missing_from_history() {
);
}
/// Active mode instructions must follow a newly migrated multi-agent usage hint.
#[test]
fn unchanged_mode_is_reemitted_after_usage_hint_migration() {
let previous = state(Some(MultiAgentMode::Proactive));
let current = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(
&MultiAgentUsageHintState::new("Current usage instructions."),
);
let instructions = current
.render_diff(PreviousSectionState::Known(&previous))
.expect("unchanged mode should follow migrated usage instructions");
assert_eq!(
instructions.render(),
MultiAgentModeInstructions::from_mode(MultiAgentMode::Proactive)
.expect("proactive mode should render")
.render()
);
}
#[test]
fn custom_mode_is_bounded_before_snapshot_and_rendering() {
let state = state(Some(MultiAgentMode::Custom("custom mode ".repeat(1_000))));
@@ -0,0 +1,45 @@
use super::PreviousSectionState;
use super::WorldStateHash;
use super::WorldStateSection;
use crate::context::ContextualUserFragment;
use crate::context::MultiAgentUsageHint;
/// Configured multi-agent instructions currently visible to the model.
#[derive(Clone, Debug)]
pub(crate) struct MultiAgentUsageHintState {
instructions: MultiAgentUsageHint,
}
impl MultiAgentUsageHintState {
pub(crate) fn new(text: &str) -> Self {
Self {
instructions: MultiAgentUsageHint::new(text),
}
}
}
impl WorldStateSection for MultiAgentUsageHintState {
const ID: &'static str = "multi_agent_usage_hint";
type Snapshot = WorldStateHash;
fn snapshot(&self) -> Self::Snapshot {
WorldStateHash::from_fragment(&self.instructions)
}
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
role == self.instructions.role() && text == self.instructions.render()
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
match previous {
PreviousSectionState::Known(previous) if previous == &self.snapshot() => None,
PreviousSectionState::Unknown => None,
PreviousSectionState::Known(_) | PreviousSectionState::Absent => {
Some(Box::new(self.instructions.clone()))
}
}
}
}
+5 -11
View File
@@ -3549,15 +3549,17 @@ impl Session {
"developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => {
initial_multi_agent_mode = Some(fragment);
}
"developer"
if fragment.requires_separate_message() && fragment.markers().0.is_empty() =>
{
separate_developer_sections.push(fragment.render());
}
"developer" => developer_sections.push(fragment.render()),
"user" => contextual_user_sections.push(fragment.render()),
_ => {}
}
}
let multi_agent_v2_usage_hint_text =
multi_agents::usage_hint_text(turn_context, &session_source);
let mut items = Vec::with_capacity(4);
if let Some(developer_message) =
crate::context_manager::updates::build_developer_update_item(developer_sections)
@@ -3571,14 +3573,6 @@ impl Session {
items.push(developer_message);
}
}
if let Some(usage_hint_text) = multi_agent_v2_usage_hint_text
&& let Some(usage_hint_message) =
crate::context_manager::updates::build_developer_update_item(vec![
usage_hint_text.to_string(),
])
{
items.push(usage_hint_message);
}
if let Some(initial_multi_agent_mode) = initial_multi_agent_mode {
items.push(initial_multi_agent_mode.into_boxed_response_item());
}
+11 -2
View File
@@ -13,6 +13,7 @@ use crate::context::world_state::EnvironmentsInstructionsState;
use crate::context::world_state::EnvironmentsState;
use crate::context::world_state::ModelInstructionsState;
use crate::context::world_state::MultiAgentModeState;
use crate::context::world_state::MultiAgentUsageHintState;
use crate::context::world_state::PermissionsState;
use crate::context::world_state::PersonalityState;
use crate::context::world_state::PluginsInstructionsState;
@@ -252,9 +253,17 @@ impl Session {
world_state.add_extension_section(section);
}
}
world_state.add_section(MultiAgentModeState::new(
let mut multi_agent_mode = MultiAgentModeState::new(
super::multi_agents::effective_multi_agent_mode(turn_context),
));
);
if let Some(usage_hint_text) =
super::multi_agents::usage_hint_text(turn_context, &turn_context.session_source)
{
let usage_hint = MultiAgentUsageHintState::new(usage_hint_text);
multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint);
world_state.add_section(usage_hint);
}
world_state.add_section(multi_agent_mode);
Ok(world_state)
}
}
@@ -877,7 +877,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema
let properties = BTreeMap::from([(
"timeout_ms".to_string(),
JsonSchema::number(Some(format!(
"Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
"Timeout in milliseconds. Defaults to {}, min {}, max {}.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
)]);
@@ -421,9 +421,7 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
properties
.get("timeout_ms")
.and_then(|schema| schema.description.as_deref()),
Some(
"Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000. Prefer longer waits (minutes) to avoid busy polling."
)
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
);
assert_eq!(parameters.required.as_ref(), None);
assert_eq!(
@@ -17,10 +17,14 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::namespace_child_tool;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -308,6 +312,378 @@ async fn configured_agent_roles_control_spawn_agent_type(
Ok(())
}
/// Wait guidance belongs to overridable developer instructions, never the tool schema.
#[test_case(None, true; "default developer instructions include wait guidance")]
#[test_case(Some("Custom root instructions."), false; "custom developer instructions replace wait guidance")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_v2_wait_guidance_uses_overridable_developer_instructions(
root_agent_usage_hint_text: Option<&str>,
expected_wait_guidance: bool,
) -> Result<()> {
let root_agent_usage_hint_text = root_agent_usage_hint_text.map(str::to_string);
let server = start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let test = test_codex()
.with_config(move |config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
if let Some(root_agent_usage_hint_text) = root_agent_usage_hint_text {
config.multi_agent_v2.root_agent_usage_hint_text = Some(root_agent_usage_hint_text);
}
})
.build_with_auto_env(&server)
.await?;
test.submit_turn("hello").await?;
let request = response.single_request();
let developer_messages = request.message_input_texts("developer");
let has_wait_guidance = developer_messages.iter().any(|message| {
message.contains(
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.",
)
});
assert_eq!(has_wait_guidance, expected_wait_guidance);
let body = request.body_json();
let wait_agent_tool = namespace_child_tool(&body, MULTI_AGENT_V2_NAMESPACE, "wait_agent")
.expect("wait_agent should be exposed");
assert_eq!(
wait_agent_tool
.pointer("/parameters/properties/timeout_ms/description")
.and_then(Value::as_str),
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
);
Ok(())
}
/// Resumed legacy threads receive current usage hints once, before their active mode.
/// Configured hint overrides and disabled wait tools must retain their existing semantics.
#[test_case(true, None, true; "legacy resume restores default wait guidance")]
#[test_case(true, Some("Custom root instructions."), false; "legacy resume preserves custom usage hints")]
#[test_case(true, Some("Legacy root instructions."), false; "legacy resume preserves unchanged custom usage hints")]
#[test_case(false, None, false; "legacy resume omits disabled wait guidance")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_v2_cold_resume_refreshes_legacy_usage_hints_once(
wait_agent_enabled: bool,
resumed_root_agent_usage_hint_text: Option<&str>,
expected_wait_guidance: bool,
) -> Result<()> {
let resumed_root_agent_usage_hint_text = resumed_root_agent_usage_hint_text.map(str::to_string);
let legacy_root_agent_usage_hint_text = "Legacy root instructions.";
let wait_guidance =
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
let config_toml = format!(
"[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {wait_agent_enabled}\n"
);
let server = start_mock_server().await;
let initial_response = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-initial"),
ev_completed("resp-initial"),
]),
)
.await;
let initial = test_codex()
.with_pre_build_hook(move |home| {
std::fs::write(home.join("config.toml"), &config_toml)
.expect("write multi-agent configuration");
})
.with_config(move |config| {
config.multi_agent_v2.root_agent_usage_hint_text =
Some(legacy_root_agent_usage_hint_text.to_string());
})
.build_with_auto_env(&server)
.await?;
initial.submit_turn("before upgrading").await?;
let initial_request = initial_response.single_request();
assert!(
initial_request
.message_input_text_groups("developer")
.iter()
.any(|group| { group.len() == 1 && group[0] == legacy_root_agent_usage_hint_text }),
"legacy usage hint should be its own developer message"
);
assert!(
!initial_request
.message_input_texts("developer")
.iter()
.any(|message| message.contains(wait_guidance)),
"legacy rollout should not already contain wait guidance"
);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("initial session should have a rollout path");
initial.codex.shutdown_and_wait().await?;
let mut removed_recorded_usage_hint = false;
let mut removed_usage_hint_presence = false;
let legacy_rollout = std::fs::read_to_string(&rollout_path)?
.lines()
.map(serde_json::from_str::<RolloutLine>)
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|mut line| {
if let RolloutItem::WorldState(world_state) = &mut line.item
&& let Some(state) = world_state.state.as_object_mut()
{
removed_recorded_usage_hint |= state.remove("multi_agent_usage_hint").is_some();
if let Some(mode) = state
.get_mut("multi_agent_mode")
.and_then(Value::as_object_mut)
{
removed_usage_hint_presence |= mode.remove("usage_hint_hash").is_some();
}
}
serde_json::to_string(&line)
})
.collect::<std::result::Result<Vec<_>, _>>()?
.join("\n");
assert!(
removed_recorded_usage_hint,
"initial rollout should contain a usage-hint world-state section"
);
assert!(
removed_usage_hint_presence,
"initial rollout should record usage-hint presence on its active mode"
);
std::fs::write(&rollout_path, format!("{legacy_rollout}\n"))?;
let resumed_responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-resumed-first"),
ev_completed("resp-resumed-first"),
]),
sse(vec![
ev_response_created("resp-resumed-second"),
ev_completed("resp-resumed-second"),
]),
],
)
.await;
let mut resumed_builder = test_codex().with_config(move |config| {
if let Some(root_agent_usage_hint_text) = resumed_root_agent_usage_hint_text {
config.multi_agent_v2.root_agent_usage_hint_text = Some(root_agent_usage_hint_text);
}
});
let resumed = resumed_builder.resume(&server, home, rollout_path).await?;
let current_usage_hint = resumed
.config
.multi_agent_v2
.root_agent_usage_hint_text
.clone()
.expect("current root usage hint should be configured");
resumed.submit_turn("first turn after upgrading").await?;
resumed.submit_turn("second turn after upgrading").await?;
let requests = resumed_responses.requests();
assert_eq!(requests.len(), 2);
for request in &requests {
let developer_messages = request.message_input_text_groups("developer");
let current_usage_hint_positions = developer_messages
.iter()
.enumerate()
.filter_map(|(index, group)| {
(group.len() == 1 && group[0] == current_usage_hint).then_some(index)
})
.collect::<Vec<_>>();
assert_eq!(
current_usage_hint_positions.len(),
1,
"current usage hint should appear exactly once as a standalone developer message: \
{developer_messages:?}"
);
let active_mode_position = developer_messages
.iter()
.rposition(|group| {
group
.iter()
.any(|message| message.contains(MULTI_AGENT_MODE_OPEN_TAG))
})
.expect("resumed context should include an active multi-agent mode");
assert!(
current_usage_hint_positions[0] < active_mode_position,
"active mode must follow the current usage hint: {developer_messages:?}"
);
let wait_guidance_count = developer_messages
.iter()
.flatten()
.filter(|message| message.contains(wait_guidance))
.count();
assert_eq!(
wait_guidance_count,
usize::from(expected_wait_guidance),
"wait guidance must follow configured tool availability and usage-hint overrides"
);
let body = request.body_json();
let wait_agent_tool = namespace_child_tool(&body, MULTI_AGENT_V2_NAMESPACE, "wait_agent");
assert_eq!(wait_agent_tool.is_some(), wait_agent_enabled);
if let Some(wait_agent_tool) = wait_agent_tool {
assert_eq!(
wait_agent_tool
.pointer("/parameters/properties/timeout_ms/description")
.and_then(Value::as_str),
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
);
}
}
Ok(())
}
/// Resuming after wait-tool availability changes must refresh stale usage guidance once.
/// The active multi-agent mode must remain after the updated instructions.
#[test_case(true, false; "disabled wait agent invalidates prior guidance")]
#[test_case(false, true; "enabled wait agent adds current guidance")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_v2_resume_refreshes_changed_wait_guidance(
initial_wait_agent_enabled: bool,
resumed_wait_agent_enabled: bool,
) -> Result<()> {
let wait_guidance =
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
let initial_config_toml = format!(
"[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {initial_wait_agent_enabled}\n"
);
let server = start_mock_server().await;
let initial_response = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-initial"),
ev_completed("resp-initial"),
]),
)
.await;
let initial = test_codex()
.with_pre_build_hook(move |home| {
std::fs::write(home.join("config.toml"), &initial_config_toml)
.expect("write initial multi-agent configuration");
})
.build_with_auto_env(&server)
.await?;
initial
.submit_turn("before changing wait-agent availability")
.await?;
let initial_request = initial_response.single_request();
assert_eq!(
namespace_child_tool(
&initial_request.body_json(),
MULTI_AGENT_V2_NAMESPACE,
"wait_agent"
)
.is_some(),
initial_wait_agent_enabled
);
let home = initial.home.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("initial session should have a rollout path");
initial.codex.shutdown_and_wait().await?;
std::fs::write(
home.path().join("config.toml"),
format!(
"[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {resumed_wait_agent_enabled}\n"
),
)?;
let resumed_responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-resumed-first"),
ev_completed("resp-resumed-first"),
]),
sse(vec![
ev_response_created("resp-resumed-second"),
ev_completed("resp-resumed-second"),
]),
],
)
.await;
let resumed = test_codex().resume(&server, home, rollout_path).await?;
let current_usage_hint = resumed
.config
.multi_agent_v2
.root_agent_usage_hint_text
.clone()
.expect("current root usage hint should be configured");
resumed
.submit_turn("first turn with updated wait-agent availability")
.await?;
resumed
.submit_turn("second turn with updated wait-agent availability")
.await?;
let requests = resumed_responses.requests();
assert_eq!(requests.len(), 2);
for request in &requests {
let developer_messages = request.message_input_text_groups("developer");
let current_usage_hint_positions = developer_messages
.iter()
.enumerate()
.filter_map(|(index, group)| {
(group.len() == 1 && group[0] == current_usage_hint).then_some(index)
})
.collect::<Vec<_>>();
assert_eq!(
current_usage_hint_positions.len(),
1,
"current usage hint should refresh earlier guidance exactly once: \
{developer_messages:?}"
);
let current_usage_hint_position = current_usage_hint_positions[0];
let current_usage_message = &developer_messages[current_usage_hint_position][0];
assert_eq!(
current_usage_message.contains(wait_guidance),
resumed_wait_agent_enabled
);
let active_mode_position = developer_messages
.iter()
.rposition(|group| {
group
.iter()
.any(|message| message.contains(MULTI_AGENT_MODE_OPEN_TAG))
})
.expect("resumed context should include an active multi-agent mode");
assert!(
current_usage_hint_position < active_mode_position,
"active mode must follow updated usage instructions: {developer_messages:?}"
);
assert_eq!(
namespace_child_tool(&request.body_json(), MULTI_AGENT_V2_NAMESPACE, "wait_agent")
.is_some(),
resumed_wait_agent_enabled
);
}
Ok(())
}
#[test_case(true, false; "wait agent remains available without clock sleep")]
#[test_case(true, true; "wait agent remains available with clock sleep")]
#[test_case(false, false; "wait agent can be disabled without clock sleep")]
@@ -360,6 +736,17 @@ wait_agent_enabled = {wait_agent_enabled}
namespace_child_tool(&body, "clock", "sleep").is_some(),
sleep_tool_enabled
);
assert_eq!(
request
.message_input_texts("developer")
.iter()
.any(|message| {
message.contains(
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.",
)
}),
wait_agent_enabled
);
Ok(())
}