Allow history and notes without experimental context capability (#47625)

## Why

Enabling `features.token_budget.use_history_notes_extension`, explicitly or through model defaults, rejected session startup when the model lacked `supports_experimental_context`, even with experimental mode disabled.

## What changed

Remove that startup restriction so token-budget history and notes can be used independently of the model's experimental context capability.

## Testing

Extend core tests to cover explicit configuration and model defaults on startup and cold resume, checking `new_context`, token-budget contexts, and `history_ingest_requested` metadata. Add app-server coverage for native history and notes tools and context-window hints without experimental context capability.

GitOrigin-RevId: 6157479aeb75ffd785b8d4b2f48b11c6f46e8f35
This commit is contained in:
pmccrary-oai
2026-09-23 18:54:02 +00:00
committed by copyberry
parent f07b26c9c9
commit fdeaa6c1ec
3 changed files with 56 additions and 44 deletions
@@ -30,13 +30,15 @@ const THREAD_HINT: &str =
"Recent notes (up to 5, most-recent first):\n- /root/notes/latest.md (2 lines, 14 UTF-8 bytes)";
const BRIDGE_HINT: &str = "unstructured notes/thread_hint fixture result";
#[test_case(true, 200, THREAD_HINT; "native_hint")]
#[test_case(true, 200, ""; "no_notes")]
#[test_case(true, 503, THREAD_HINT; "native_failure_does_not_use_bridge")]
#[test_case(false, 200, THREAD_HINT; "bridge_hint")]
#[test_case(true, true, 200, THREAD_HINT; "native_hint")]
#[test_case(true, false, 200, THREAD_HINT; "native_hint_without_experimental_capability")]
#[test_case(true, true, 200, ""; "no_notes")]
#[test_case(true, true, 503, THREAD_HINT; "native_failure_does_not_use_bridge")]
#[test_case(false, true, 200, THREAD_HINT; "bridge_hint")]
#[tokio::test]
async fn app_server_uses_configured_notes_backend_for_context_window_hints(
use_history_notes_extension: bool,
supports_experimental_context: bool,
hint_status: u16,
hint_text: &str,
) -> Result<()> {
@@ -115,7 +117,7 @@ async fn app_server_uses_configured_notes_backend_for_context_window_hints(
let codex_home = TempDir::new()?;
let config = load_default_config_for_test(&codex_home).await;
let mut model = codex_core::test_support::construct_model_info_offline("mock-model", &config);
model.supports_experimental_context = true;
model.supports_experimental_context = supports_experimental_context;
let catalog_path = codex_home.path().join("models.json");
std::fs::write(
&catalog_path,
@@ -132,7 +134,7 @@ async fn app_server_uses_configured_notes_backend_for_context_window_hints(
.with_provider_base_url(&format!("{}/backend-api/codex", server.uri()))
.with_provider_config("supports_websockets = false\nrequires_openai_auth = true")
.with_extra_config(&format!(
"[features.token_budget]\nenabled = true\nuse_history_notes_extension = {use_history_notes_extension}\n\n[mcp_servers.notes]\nurl = \"{}/mcp\"\nstartup_timeout_sec = 10\n",
"[features.context_management]\nexperimental_mode = false\n\n[features.token_budget]\nenabled = true\nuse_history_notes_extension = {use_history_notes_extension}\n\n[mcp_servers.notes]\nurl = \"{}/mcp\"\nstartup_timeout_sec = 10\n",
server.uri(),
))
.write(codex_home.path())?;
-10
View File
@@ -726,16 +726,6 @@ impl Session {
&model_info,
)?;
token_budget::apply_model_defaults(Arc::make_mut(&mut config), &model_info);
if config
.token_budget
.as_ref()
.is_some_and(|token_budget| token_budget.use_history_notes_extension)
&& !model_info.supports_experimental_context
{
return Err(CodexErr::InvalidRequest(format!(
"features.token_budget.use_history_notes_extension is not supported by model `{model}`; disable it or select a model that supports experimental context"
)));
}
}
let configured_config = Arc::clone(&config);
let multi_agent_version = config.multi_agent_version_override().or_else(|| {
+48 -28
View File
@@ -364,14 +364,14 @@ async fn experimental_context_requires_capable_model_and_codex_backend(
Ok(())
}
#[test_case(false, true, false; "explicit_history_notes_unsupported")]
#[test_case(false, true, true; "explicit_history_notes_supported")]
#[test_case(false, true, false; "explicit_history_notes_without_experimental_capability")]
#[test_case(false, true, true; "explicit_history_notes_with_experimental_capability")]
#[test_case(false, false, false; "explicit_standalone_token_budget")]
#[test_case(true, true, false; "model_default_history_notes_unsupported")]
#[test_case(true, true, true; "model_default_history_notes_supported")]
#[test_case(true, true, false; "model_default_history_notes_without_experimental_capability")]
#[test_case(true, true, true; "model_default_history_notes_with_experimental_capability")]
#[test_case(true, false, false; "model_default_standalone_token_budget")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_budget_history_notes_requires_capable_starting_model(
async fn token_budget_history_notes_can_be_enabled_without_experimental_context(
use_model_defaults: bool,
use_history_notes: bool,
supports_context: bool,
@@ -380,7 +380,7 @@ async fn token_budget_history_notes_requires_capable_starting_model(
let server = start_mock_server().await;
let backend_url = format!("{}/backend-api/codex", server.uri());
let result = test_codex()
let mut builder = test_codex()
.with_auth(CodexAuth::from_external_chatgpt_tokens(
"header.e30.signature",
"account-123",
@@ -411,30 +411,50 @@ async fn token_budget_history_notes_requires_capable_starting_model(
.with_config(move |config| {
config.model_provider.base_url = Some(backend_url);
config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW);
})
.build_with_auto_env(&server)
.await;
});
let test = builder.build_with_auto_env(&server).await?;
if use_history_notes && !supports_context {
let error = result
.err()
.expect("unsupported history/notes must fail at startup");
assert!(error.to_string().contains(
"features.token_budget.use_history_notes_extension is not supported by model `gpt-5.2`"
));
return Ok(());
}
let test = result?;
let response = mount_sse_once(&server, sse_completed("resp-1")).await;
let response = mount_sse_sequence(
&server,
vec![sse_completed("resp-1"), sse_completed("resp-2")],
)
.await;
test.submit_turn("inspect token-budget activation").await?;
let request = response.single_request();
assert!(
tool_names(&request)
.iter()
.any(|name| name == "new_context")
);
assert_eq!(token_budget_contexts(&request).len(), 1);
// Builder overrides are consumed by the initial build. Reapply the model and
// provider settings so the cold resume exercises the same activation defaults.
let initial_config = test.config.clone();
let resumed = builder
.with_config(move |config| {
config.model = initial_config.model;
config.model_catalog = initial_config.model_catalog;
config.model_provider = initial_config.model_provider;
config.model_context_window = initial_config.model_context_window;
})
.restart(&server, &test)
.await?;
resumed
.submit_turn("inspect token-budget activation after restart")
.await?;
let requests = response.requests();
assert_eq!(requests.len(), 2);
for request in requests {
assert!(
tool_names(&request)
.iter()
.any(|name| name == "new_context")
);
assert_eq!(token_budget_contexts(&request).len(), 1);
let turn_metadata: Value = serde_json::from_str(
&request
.header("x-codex-turn-metadata")
.expect("request should include turn metadata"),
)?;
assert_eq!(
turn_metadata["history_ingest_requested"].as_bool(),
use_history_notes.then_some(true)
);
}
Ok(())
}