Allow per-turn overrides for direct-only tool namespaces (#59481)

## What changed

- Add `direct_only_tool_namespaces` to `TurnStartOptions`. Omission inherits the thread configuration; an empty list clears direct-only routing for that turn. Steering ignores the option.
- Persist explicit overrides in turn context and restore them when recovering an interrupted turn, including turns started by standalone tool output.
- Keep subsequent turns and spawned or resumed child agents on the thread's routing defaults.

## Testing

Add coverage for routing changes across turns in Responses and Responses Lite, recovery without carrying overrides into subsequent turns, and child-agent inheritance.

GitOrigin-RevId: cea1a997424572d4cd372cd15b098626904c6796
This commit is contained in:
Henry Levy
2026-11-04 19:58:49 +00:00
committed by copyberry
parent 41cbd5b9b2
commit d78f68f8d1
24 changed files with 322 additions and 39 deletions
@@ -1,6 +1,7 @@
use anyhow::Result;
use codex_core::RecoverTurnRequest;
use codex_core::StartIfIdleSubmission;
use codex_core::TurnInput;
use codex_core::TurnInputRequest;
use codex_core::TurnInputSubmission;
use codex_core::TurnStartOptions;
@@ -12,6 +13,10 @@ use codex_protocol::turn_input::CyberAccessProgram;
use codex_protocol::user_input::UserInput;
use codex_rollout::RolloutItem;
use codex_rollout::RolloutRecorder;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
use core_test_support::apps_test_server::search_capable_apps_builder;
use core_test_support::responses;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
@@ -27,35 +32,64 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[test_case(false; "user_input")]
#[test_case(true; "standalone_tool_output")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() -> Result<()> {
async fn recover_turn_restores_start_options_without_making_them_sticky(
standalone_tool_output: bool,
) -> Result<()> {
// Keep sampling pending until interruption so restart must reload the unfinished turn.
let (release_response, response_gate) = oneshot::channel();
let (initial_server, _completions) =
start_streaming_sse_server(vec![vec![StreamingSseChunk {
let (initial_server, _completions) = start_streaming_sse_server(vec![
vec![StreamingSseChunk {
gate: None,
body: responses::sse_completed("seed"),
}],
vec![StreamingSseChunk {
gate: Some(response_gate),
body: responses::sse(vec![responses::ev_completed("resp-initial")]),
}]])
.await;
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let initial = builder.build_with_streaming_server(&initial_server).await?;
}],
])
.await;
let server = responses::start_mock_server().await;
let apps = AppsTestServer::mount_searchable(&server).await?;
let builder = || {
search_capable_apps_builder(apps.chatgpt_base_url.clone()).with_config(|config| {
config
.features
.enable(Feature::CodeModeOnly)
.expect("enable code mode only");
config.code_mode.direct_only_tool_namespaces =
vec![SEARCH_CALENDAR_NAMESPACE.to_owned()];
})
};
let initial = builder()
.build_with_streaming_server(&initial_server)
.await?;
initial.submit_text_turn("seed user turn").await?;
let request = if standalone_tool_output {
TurnInputRequest::new(TurnInput::ResponseItem(serde_json::from_value(json!({
"type": "function_call_output", "name": "result", "namespace": "example",
"output": "recover this tool output",
}))?))
} else {
TurnInputRequest::user_input(vec![UserInput::Text {
text: "recover this turn".to_owned(),
text_elements: Vec::new(),
}])
};
let TurnInputSubmission::Started { turn_id, .. } = initial
.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "recover this turn".to_owned(),
text_elements: Vec::new(),
}])
.on_start(TurnStartOptions {
cyber_access_program: Some(CyberAccessProgram::DaybreakBlue),
..Default::default()
}),
)
.start_or_steer_turn(request.on_start(TurnStartOptions {
direct_only_tool_namespaces: Some(Vec::new()),
cyber_access_program: Some(CyberAccessProgram::DaybreakBlue),
..Default::default()
}))
.await?
else {
panic!("expected a new turn");
};
initial_server.wait_for_request_count(/*count*/ 1).await;
initial_server.wait_for_request_count(/*count*/ 2).await;
initial.codex.submit(Op::Interrupt).await?;
wait_for_event(&initial.codex, |event| {
matches!(event, EventMsg::TurnAborted(_))
@@ -65,8 +99,7 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() -
initial_server.shutdown().await;
initial.codex.flush_rollout().await?;
let server = responses::start_mock_server().await;
let test = builder.restart(&server, &initial).await?;
let test = builder().restart(&server, &initial).await?;
let (rollout, _, _) = RolloutRecorder::load_rollout_items(
&test
.codex
@@ -91,6 +124,11 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() -
Some(CyberAccessProgram::DaybreakBlue)
);
assert_eq!(
persisted_context.direct_only_tool_namespaces,
Some(Vec::new())
);
let response_mock =
responses::mount_sse_once(&server, responses::sse_completed("resp-1")).await;
let submission = test
@@ -118,15 +156,33 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() -
response_mock.single_request().body_json()["access_programs"],
json!({"cyber": "daybreak_blue"})
);
assert_eq!(
response_mock
.single_request()
let recovered_request = response_mock.single_request();
let recovered_input_count = if standalone_tool_output {
recovered_request
.inputs_of_type("function_call_output")
.iter()
.filter(|item| item["name"] == "result")
.count()
} else {
recovered_request
.message_input_texts("user")
.into_iter()
.filter(|text| text == "recover this turn")
.count(),
1
);
.iter()
.filter(|text| text.as_str() == "recover this turn")
.count()
};
assert_eq!(recovered_input_count, 1);
let declares_calendar = |body: &serde_json::Value| {
responses::namespace_child_tool(
body,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL,
)
.is_some()
};
assert!(!declares_calendar(
&response_mock.single_request().body_json()
));
let next_response = responses::mount_sse_once(
&server,
@@ -158,6 +214,9 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() -
.get("access_programs"),
None
);
assert!(declares_calendar(
&next_response.single_request().body_json()
));
test.codex.shutdown_and_wait().await?;
Ok(())
}
@@ -2,6 +2,7 @@ use anyhow::Result;
use codex_config::Constrained;
use codex_core::EnvironmentConfig;
use codex_core::TurnInputRequest;
use codex_core::TurnStartOptions;
use codex_core::config::Config;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_extension_api::ExtensionFuture;
@@ -1086,6 +1087,106 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()>
Ok(())
}
#[test_case::test_case(false; "responses")]
#[test_case::test_case(true; "responses_lite")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_refreshes_direct_tool_routing_between_turns(
use_responses_lite: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps = AppsTestServer::mount_searchable(&server).await?;
let calendar = vec![SEARCH_CALENDAR_NAMESPACE.to_owned()];
let cases = [
(None, false),
(Some(calendar.clone()), true),
(Some(Vec::new()), false),
(Some(calendar), true),
(None, false),
];
let responses = mount_sse_sequence(&server, completed_response_sequence(cases.len())).await;
let test = search_capable_apps_builder(apps.chatgpt_base_url)
.with_model_info_override("gpt-5.5", move |model| {
model.use_responses_lite = use_responses_lite
})
.with_config(|config| {
config
.features
.enable(Feature::CodeModeOnly)
.expect("enable code mode only");
config
.features
.enable(Feature::IncrementalTools)
.expect("enable incremental tools");
})
.build(&server)
.await?;
let mut previous_input = Vec::new();
for (index, (direct_only_tool_namespaces, expect_direct)) in cases.into_iter().enumerate() {
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "inspect calendar tools".into(),
text_elements: Vec::new(),
}])
.on_start(TurnStartOptions {
direct_only_tool_namespaces,
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = responses.requests();
assert_eq!(requests.len(), index + 1);
let body = requests[index].body_json();
let declares_calendar = |item: &Value| {
namespace_child_tool(item, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL)
.is_some()
};
if use_responses_lite {
let input = body["input"].as_array().expect("Responses Lite input");
assert!(
input.starts_with(&previous_input),
"history changed on turn {index}"
);
let appended = &input[previous_input.len()..];
assert_eq!(
appended
.iter()
.filter(|item| item["type"] == "additional_tools")
.any(declares_calendar),
expect_direct,
"calendar declaration on turn {index}",
);
if index > 0 && !expect_direct {
let removed = format!("- {SEARCH_CALENDAR_NAMESPACE}");
assert!(
appended.iter().any(|item| {
item["role"] == "developer"
&& item["content"][0]["text"]
.as_str()
.is_some_and(|text| text.lines().any(|line| line == removed))
}),
"missing calendar removal on turn {index}"
);
}
previous_input.clone_from(input);
} else {
assert_eq!(
declares_calendar(&body),
expect_direct,
"calendar exposure on turn {index}"
);
}
}
Ok(())
}
// Keep spawned tasks on the thread with the scoped tracing subscriber.
#[tokio::test(flavor = "current_thread")]
async fn deferred_tool_world_state_tracks_initial_unchanged_and_removed_namespaces() -> Result<()> {
@@ -31,6 +31,7 @@ fn resume_history(
let turn_ctx = TurnContextItem {
turn_id: Some(turn_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
cwd: config.cwd.clone(),
approval_policy: config.permissions.approval_policy.value(),