mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
BIN
Binary file not shown.
@@ -850,6 +850,7 @@ mod tests {
|
||||
item: RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some((*turn_id).to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: AbsolutePathBuf::from_absolute_path(tempdir.path())
|
||||
.expect("absolute feedback rollout directory"),
|
||||
|
||||
@@ -68,6 +68,7 @@ fn turn_context_item(
|
||||
RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some(turn_id.to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: cwd(),
|
||||
approval_policy,
|
||||
|
||||
@@ -694,12 +694,12 @@ impl TurnRequestProcessor {
|
||||
.on_start(TurnStartOptions {
|
||||
turn_trigger: params.turn_trigger,
|
||||
parent_turn_id: params.parent_turn_id,
|
||||
initiating_agent_path: None,
|
||||
root_turn_id: params.root_turn_id,
|
||||
final_output_json_schema: params.output_schema,
|
||||
service_tier: params.service_tier_for_turn,
|
||||
prompt_cache_ttl: None,
|
||||
cyber_access_program: params.cyber_access_program.map(Into::into),
|
||||
..Default::default()
|
||||
})
|
||||
.with_additional_context(additional_context)
|
||||
.with_responses_metadata(params.responsesapi_client_metadata)
|
||||
|
||||
@@ -143,6 +143,12 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, String> {
|
||||
// Preserve activation for history forks without freezing the parent's model-owned prompts.
|
||||
// Fresh child startup restores configured preferences from the retained snapshot.
|
||||
config.token_budget = turn.configured_token_budget.clone();
|
||||
if let Some(thread_namespaces) = &turn.overridden_direct_only_tool_namespaces {
|
||||
config
|
||||
.code_mode
|
||||
.direct_only_tool_namespaces
|
||||
.clone_from(thread_namespaces);
|
||||
}
|
||||
config.model = Some(turn.model_info().slug.clone());
|
||||
config.model_provider = turn.provider.info().clone();
|
||||
config.model_reasoning_effort = turn
|
||||
|
||||
@@ -845,6 +845,7 @@ fn reference_context_item() -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some("reference-turn".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: AbsolutePathBuf::try_from(
|
||||
std::env::current_dir()
|
||||
|
||||
@@ -1950,6 +1950,23 @@ impl Session {
|
||||
} = self
|
||||
.reconstruct_history_from_rollout(turn_context, rollout_items)
|
||||
.await;
|
||||
// An automatic turn can have a persisted routing override without establishing the
|
||||
// user-turn context baseline. Recover its own latest context by surviving turn ID.
|
||||
let restored_turn_routing = turn_attribution.as_ref().and_then(|turn| {
|
||||
rollout_items
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|item| match item {
|
||||
RolloutItem::TurnContext(context)
|
||||
if context.turn_id.as_deref() == Some(turn.turn_id.as_str()) =>
|
||||
{
|
||||
Some(context)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.and_then(|context| context.direct_only_tool_namespaces.clone())
|
||||
.map(|namespaces| (turn.turn_id.clone(), namespaces))
|
||||
});
|
||||
// Keep the recorded rollout unchanged. Prepare its reconstructed history before
|
||||
// installing it, so legacy media is processed once for this resume or fork and
|
||||
// will be processed again if the rollout is reconstructed in a future session.
|
||||
@@ -2015,6 +2032,7 @@ impl Session {
|
||||
);
|
||||
state.last_started_turn_id = last_started_turn_id;
|
||||
state.turn_attribution = turn_attribution;
|
||||
state.restored_turn_routing = restored_turn_routing;
|
||||
if let Some(world_state) = world_state_baseline {
|
||||
state.history.set_world_state_baseline(world_state);
|
||||
}
|
||||
|
||||
@@ -466,6 +466,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -512,6 +513,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -2167,6 +2169,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: Some("root-turn".to_string()),
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -2265,6 +2268,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
serde_json::to_value(Some(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: Some("root-turn".to_string()),
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -2295,6 +2299,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -2438,6 +2443,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
let current_context_item = TurnContextItem {
|
||||
turn_id: Some(current_turn_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -2573,6 +2579,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
@@ -2758,6 +2765,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
|
||||
@@ -4439,6 +4439,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
|
||||
@@ -335,6 +335,7 @@ impl std::fmt::Debug for TurnEnvironment {
|
||||
/// Request options for one turn, separate from persistent thread settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct NewTurnContextOptions {
|
||||
pub(crate) direct_only_tool_namespaces: Option<Vec<String>>,
|
||||
pub(crate) final_output_json_schema: Option<Value>,
|
||||
pub(crate) prompt_cache_ttl: Option<crate::PromptCacheTtl>,
|
||||
pub(crate) cyber_access_program: Option<CyberAccessProgram>,
|
||||
@@ -351,6 +352,8 @@ pub struct TurnContext {
|
||||
/// Turn-scoped configuration. Read step-specific settings such as service tier and
|
||||
/// approvals reviewer from the corresponding `StepContext` instead.
|
||||
pub config: Arc<Config>,
|
||||
/// Thread defaults displaced by an explicit routing override for this turn.
|
||||
pub(crate) overridden_direct_only_tool_namespaces: Option<Vec<String>>,
|
||||
/// Preferences captured before token-budget defaults from the turn's initial model.
|
||||
pub(crate) configured_token_budget: Option<TokenBudgetConfig>,
|
||||
/// Captured once so later steps do not re-read config layers to detect user preferences.
|
||||
@@ -451,6 +454,7 @@ impl TurnContext {
|
||||
trace_id: current_span_trace_id(),
|
||||
realtime_active: self.realtime_active,
|
||||
code_mode_available: self.code_mode_available,
|
||||
overridden_direct_only_tool_namespaces: None,
|
||||
configured_token_budget: config.token_budget.clone(),
|
||||
use_model_token_budget_defaults: config.features.enabled(Feature::TokenBudget)
|
||||
&& !has_explicit_settings(&config),
|
||||
@@ -894,6 +898,9 @@ impl TurnContext {
|
||||
realtime_active: self.realtime_active,
|
||||
code_mode_available: self.code_mode_available,
|
||||
config: Arc::new(config),
|
||||
overridden_direct_only_tool_namespaces: self
|
||||
.overridden_direct_only_tool_namespaces
|
||||
.clone(),
|
||||
configured_token_budget: self.configured_token_budget.clone(),
|
||||
use_model_token_budget_defaults: self.use_model_token_budget_defaults,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
@@ -962,6 +969,10 @@ impl TurnContext {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
root_turn_id: self.turn_metadata_state.root_turn_id(),
|
||||
direct_only_tool_namespaces: self
|
||||
.overridden_direct_only_tool_namespaces
|
||||
.as_ref()
|
||||
.map(|_| self.config.code_mode.direct_only_tool_namespaces.clone()),
|
||||
disabled_plugin_ids: Some(self.disabled_plugin_ids.clone()),
|
||||
cwd,
|
||||
approval_policy: self.approval_policy(),
|
||||
@@ -1180,6 +1191,7 @@ impl Session {
|
||||
realtime_active: false,
|
||||
code_mode_available: true,
|
||||
config: per_turn_config,
|
||||
overridden_direct_only_tool_namespaces: None,
|
||||
configured_token_budget,
|
||||
use_model_token_budget_defaults,
|
||||
auth_manager,
|
||||
@@ -1345,8 +1357,15 @@ impl Session {
|
||||
git_enrichment_policy: GitEnrichmentPolicy,
|
||||
) -> Arc<TurnContext> {
|
||||
let primary_turn_environment = turn_environments.primary();
|
||||
let per_turn_config =
|
||||
let mut per_turn_config =
|
||||
self.build_per_turn_config(&session_configuration, turn_environments.local_selection());
|
||||
let overridden_direct_only_tool_namespaces =
|
||||
options.direct_only_tool_namespaces.map(|namespaces| {
|
||||
std::mem::replace(
|
||||
&mut per_turn_config.code_mode.direct_only_tool_namespaces,
|
||||
namespaces,
|
||||
)
|
||||
});
|
||||
// Legacy turn consumers still refer to the primary executor's repository.
|
||||
let cwd = primary_turn_environment
|
||||
.and_then(|environment| environment.cwd().to_abs_path().ok())
|
||||
@@ -1475,6 +1494,8 @@ impl Session {
|
||||
if let Some(discovery) = skill_discovery {
|
||||
turn_context.extension_data.insert(discovery);
|
||||
}
|
||||
turn_context.overridden_direct_only_tool_namespaces =
|
||||
overridden_direct_only_tool_namespaces;
|
||||
turn_context.code_mode_available = self.services.code_mode_service.is_available();
|
||||
turn_context.extension_data.insert(trusted_plugin_roots);
|
||||
turn_context.active_host_plugin_identities = Some(
|
||||
|
||||
@@ -151,6 +151,7 @@ impl PreparedTurnInputSettings {
|
||||
let TurnStartOptions {
|
||||
turn_trigger,
|
||||
final_output_json_schema,
|
||||
direct_only_tool_namespaces,
|
||||
service_tier,
|
||||
prompt_cache_ttl,
|
||||
parent_turn_id,
|
||||
@@ -168,6 +169,7 @@ impl PreparedTurnInputSettings {
|
||||
updates.service_tier_for_turn = service_tier;
|
||||
|
||||
let options = NewTurnContextOptions {
|
||||
direct_only_tool_namespaces,
|
||||
final_output_json_schema,
|
||||
prompt_cache_ttl,
|
||||
cyber_access_program,
|
||||
|
||||
@@ -96,6 +96,8 @@ pub(crate) struct SessionState {
|
||||
pub(crate) last_started_turn_id: Option<String>,
|
||||
/// Latest regular turn, retained across completion, settings changes, and compaction.
|
||||
pub(crate) turn_attribution: Option<TurnAttribution>,
|
||||
/// Explicit namespaces from the recovered turn's own rollout record.
|
||||
pub(crate) restored_turn_routing: Option<(String, Vec<String>)>,
|
||||
/// Runtime accounting state for the active auto-compaction window.
|
||||
auto_compact_window: AutoCompactWindow,
|
||||
context_window_id_generator: ContextWindowIdGenerator,
|
||||
@@ -150,6 +152,7 @@ impl SessionState {
|
||||
previous_turn_settings: None,
|
||||
last_started_turn_id: None,
|
||||
turn_attribution: None,
|
||||
restored_turn_routing: None,
|
||||
auto_compact_window: AutoCompactWindow::new_with_ids(auto_compact_window_ids),
|
||||
context_window_id_generator,
|
||||
async_compaction: crate::session::async_compaction::AsyncCompactionState::default(),
|
||||
@@ -223,6 +226,7 @@ impl SessionState {
|
||||
.replace_compacted(items, reviewer_compaction_hash.as_deref()),
|
||||
HistoryReplacement::Reset => {
|
||||
self.turn_attribution = None;
|
||||
self.restored_turn_routing = None;
|
||||
self.history.replace_annotated(items);
|
||||
true
|
||||
}
|
||||
@@ -286,18 +290,25 @@ impl SessionState {
|
||||
}
|
||||
|
||||
pub(crate) fn recovered_turn_start_options(&self, turn_id: &str) -> TurnStartOptions {
|
||||
self.turn_attribution
|
||||
let mut options = self
|
||||
.turn_attribution
|
||||
.as_ref()
|
||||
.filter(|attribution| attribution.turn_id == turn_id)
|
||||
.map(TurnAttribution::start_options)
|
||||
.unwrap_or_else(|| TurnStartOptions {
|
||||
// Older rollouts only persisted the root on the model-context record.
|
||||
root_turn_id: self
|
||||
.reference_context_item()
|
||||
.filter(|context| context.turn_id.as_deref() == Some(turn_id))
|
||||
.and_then(|context| context.root_turn_id),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(context) = self
|
||||
.reference_context_item()
|
||||
.filter(|context| context.turn_id.as_deref() == Some(turn_id))
|
||||
{
|
||||
// Older rollouts only persisted the root on the model-context record.
|
||||
options.root_turn_id = options.root_turn_id.or(context.root_turn_id);
|
||||
options.direct_only_tool_namespaces = context.direct_only_tool_namespaces;
|
||||
} else if let Some((id, namespaces)) = &self.restored_turn_routing
|
||||
&& id == turn_id
|
||||
{
|
||||
options.direct_only_tool_namespaces = Some(namespaces.clone());
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
// Token/rate limit helpers
|
||||
|
||||
@@ -556,6 +556,7 @@ impl Session {
|
||||
.and_then(|context| context.cyber_access_program);
|
||||
}
|
||||
let options = NewTurnContextOptions {
|
||||
direct_only_tool_namespaces: start_options.direct_only_tool_namespaces,
|
||||
final_output_json_schema: start_options.final_output_json_schema,
|
||||
prompt_cache_ttl: start_options.prompt_cache_ttl,
|
||||
cyber_access_program: start_options.cyber_access_program,
|
||||
|
||||
@@ -4689,6 +4689,41 @@ async fn build_agent_spawn_config_uses_captured_step_settings_and_turn_context_v
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_agents_inherit_thread_routing_when_parent_overrides_it_for_one_turn() {
|
||||
let home = tempfile::tempdir().expect("create test home");
|
||||
let (session, _, _events) = make_session_and_context_with_auth_config_home_and_rx(
|
||||
CodexAuth::from_api_key("test"),
|
||||
Vec::new(),
|
||||
home.path(),
|
||||
|config| config.code_mode.direct_only_tool_namespaces = vec!["thread_tools".into()],
|
||||
)
|
||||
.await;
|
||||
let turn = session
|
||||
.new_turn_with_default_settings(
|
||||
"parent-turn".into(),
|
||||
crate::session::turn_context::NewTurnContextOptions {
|
||||
direct_only_tool_namespaces: Some(vec!["user_message".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
turn.config.code_mode.direct_only_tool_namespaces,
|
||||
["user_message"]
|
||||
);
|
||||
let resumed = build_agent_resume_config(&turn).expect("resume config");
|
||||
let step = StepContext::for_test(turn);
|
||||
let spawned =
|
||||
build_agent_spawn_config(&BaseInstructions::default(), &step).expect("spawn config");
|
||||
for config in [spawned, resumed] {
|
||||
assert_eq!(
|
||||
config.code_mode.direct_only_tool_namespaces,
|
||||
["thread_tools"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_agent_resume_config_clears_base_instructions() {
|
||||
let (_session, mut turn) = make_session_and_context().await;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -3634,6 +3634,10 @@ pub struct TurnContextItem {
|
||||
/// Root turn that owns this turn's attribution, retained across recovery.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub root_turn_id: Option<String>,
|
||||
/// Explicit routing for this turn. An empty list clears the thread's direct namespaces.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub direct_only_tool_namespaces: Option<Vec<String>>,
|
||||
/// Plugin selection captured for this turn. Absent in older histories.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
@@ -6594,6 +6598,7 @@ mod tests {
|
||||
let item = TurnContextItem {
|
||||
turn_id: None,
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct TurnStartOptions {
|
||||
/// Structured-output schema for a new turn. When steering, Core rejects
|
||||
/// the input if the active turn uses a different schema.
|
||||
pub final_output_json_schema: Option<Value>,
|
||||
/// Direct-only tool namespaces for a new turn. Omission inherits the thread's
|
||||
/// configuration. An empty list clears it for this turn. Ignored when steering.
|
||||
pub direct_only_tool_namespaces: Option<Vec<String>>,
|
||||
/// Service tier for a new turn, without changing the thread's preference.
|
||||
pub service_tier: Option<String>,
|
||||
/// Lifetime requested for new Responses prompt cache entries during this turn.
|
||||
|
||||
@@ -2013,6 +2013,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
|
||||
item: RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(&latest_cwd))
|
||||
.expect("absolute latest cwd"),
|
||||
|
||||
@@ -578,6 +578,7 @@ mod tests {
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(
|
||||
std::env::current_dir()
|
||||
@@ -622,6 +623,7 @@ mod tests {
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(
|
||||
std::env::current_dir()
|
||||
@@ -666,6 +668,7 @@ mod tests {
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(&fallback_cwd))
|
||||
.expect("absolute fallback cwd"),
|
||||
@@ -699,6 +702,7 @@ mod tests {
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(
|
||||
std::env::current_dir()
|
||||
|
||||
@@ -1141,6 +1141,7 @@ mod tests {
|
||||
RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(cwd)).expect("absolute cwd"),
|
||||
approval_policy,
|
||||
|
||||
@@ -742,6 +742,7 @@ fn turn_context(root: &Path, turn_id: &str) -> RolloutItem {
|
||||
RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some(turn_id.to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(serde_json::json!(root)).expect("absolute cwd"),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -2217,6 +2217,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() {
|
||||
RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("child-turn".to_string()),
|
||||
root_turn_id: None,
|
||||
direct_only_tool_namespaces: None,
|
||||
disabled_plugin_ids: None,
|
||||
cwd: serde_json::from_value(json!(home.path())).expect("absolute cwd"),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
Reference in New Issue
Block a user