Emit one developer notice per incremental namespace update batch (#52295)

## What changed

Move incremental tool guidance out of namespace descriptions and into a developer message preceding each namespace declaration batch after the initial catalog. The notice covers newly added namespaces as well as changed ones, explaining that earlier tools remain available unless explicitly removed and that newer definitions replace earlier ones.

Retain the notice alongside tool declarations in full-history agent forks, and discard it when legacy compaction forks rebuild those declarations.

## Testing

Add unit coverage for multiple new namespaces sharing one notice. Update request-history snapshots for tool changes, removals and restoration, and regular Responses requests. Extend resume and fork tests to cover notice preservation without duplication and removal when rebuilding declarations.

GitOrigin-RevId: 05f79f5eb378a355cf935eabf63951015a9b454b
This commit is contained in:
Alex Kira
2026-10-08 22:01:24 +00:00
committed by copyberry
parent 77c01c1d37
commit 0b755b1945
14 changed files with 367 additions and 310 deletions
+5 -1
View File
@@ -20,6 +20,7 @@ use crate::context::DeveloperInstructions;
use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::context::world_state::IncrementalToolsHint;
use crate::context::world_state::PersistentModeState;
use crate::session::multi_agents::resolve_usage_hints;
use crate::thread_manager::StartThreadOptions;
@@ -1150,7 +1151,10 @@ impl LocalAgentControl {
return false;
};
content.retain_mut(|content_item| {
if content_item.kind().as_str() == BaseInstructionsFragment::KIND {
if matches!(
content_item.kind().as_str(),
BaseInstructionsFragment::KIND | IncrementalToolsHint::KIND
) {
return preserve_context_baselines;
}
let ContentItem::InputText { text } = content_item.content_mut() else {
+38 -3
View File
@@ -25,6 +25,7 @@ use crate::context::ContextualUserFragment;
use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::context::SubagentNotification;
use crate::context::world_state::IncrementalToolsHint;
use crate::init_state_db;
use crate::responses_metadata::CodexResponsesRequestKind;
use crate::session::SessionSettingsUpdate;
@@ -3187,7 +3188,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke
role: "developer".to_string(),
tools: vec![serde_json::json!({"type": "function", "name": "compacted_tool"})],
};
let tools_update = ContextualUserFragment::into(IncrementalToolsHint);
let replacement_history = vec![
tools_update.clone(),
tool_declarations.clone(),
ContextualUserFragment::into(crate::context::GuardianApprovedAction::new("parent-private-release".to_owned())),
ResponseItem::Message {
@@ -3335,12 +3338,19 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(marke
strip_response_item_ids(
&history
.raw_items()
.filter(|item| matches!(item, ResponseItem::AdditionalTools { .. }))
.filter(|item| {
matches!(item, ResponseItem::AdditionalTools { .. })
|| matches!(item, ResponseItem::Message {
internal_chat_message_metadata_passthrough: Some(metadata), ..
} if metadata.content_item_kinds.as_ref().is_some_and(|kinds| {
kinds.iter().any(|kind| kind.as_str() == IncrementalToolsHint::KIND)
}))
})
.cloned()
.collect::<Vec<_>>()
),
vec![tool_declarations],
"full-history forks must retain declarations embedded in compaction checkpoints"
vec![tools_update, tool_declarations],
"full-history forks must retain tool update notices with declarations embedded in compaction checkpoints"
);
assert!(
!history_contains_text(
@@ -3684,6 +3694,14 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
phase: None,
internal_chat_message_metadata_passthrough: None,
}));
rollout_items.push(rollout_response_item(ContextualUserFragment::into(
IncrementalToolsHint,
)));
rollout_items.push(rollout_response_item(ResponseItem::AdditionalTools {
id: None,
role: "developer".to_string(),
tools: vec![serde_json::json!({"type": "function", "name": "parent_tool"})],
}));
rollout_items.push(RolloutItem::TurnContext(
turn_context.to_turn_context_item(),
));
@@ -3739,6 +3757,23 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
)
.await
.expect("read child checkpoint");
assert_eq!(
items
.iter()
.filter_map(|item| match item {
RolloutItem::ResponseItem(envelope)
if matches!(&envelope.item, ResponseItem::Message {
internal_chat_message_metadata_passthrough: Some(metadata), ..
} if metadata.content_item_kinds.as_ref().is_some_and(|kinds| {
kinds.iter().any(|kind| kind.as_str() == IncrementalToolsHint::KIND)
})) =>
Some(envelope.item.clone()),
_ => None,
})
.collect::<Vec<_>>(),
Vec::<ResponseItem>::new(),
"{case}: a legacy compaction fork must drop notices for tool declarations it rebuilds"
);
let inherited_state = items.into_iter().find_map(|item| match item {
RolloutItem::Compacted(item) => item.resume_metadata,
_ => None,
@@ -58,6 +58,7 @@ pub(crate) use persistent_mode::PersistentModeState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
pub(crate) use realtime::RealtimeState;
pub(crate) use tools::ToolsState;
pub(crate) use top_level_tools::IncrementalToolsHint;
pub(crate) use top_level_tools::TopLevelToolsState;
/// One contribution to model context and its placement policy.
@@ -64,11 +64,35 @@ Absent -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.cha
None
{"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.added":"5fc75ad4d08cd0b73ef983d58fed9272072e5ce7","functions.changed":"ebe9005f973a60ba4ee93b024d7021d21dc27826","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix)
{
"content": [
{
"text": "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.",
"type": "input_text"
}
],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": [
"tools.incremental_update"
],
"content_item_metadata": [
{
"provenance": {
"hook": false,
"type": "harness"
}
}
]
},
"role": "developer",
"type": "message"
}
(item - prefix)
{
"role": "developer",
"tools": [
{
"description": "Tools.\nThis is an incremental namespace update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.",
"description": "Tools.",
"name": "functions",
"tools": [
{
@@ -108,6 +132,30 @@ The following namespaces are no longer available. Do not call tools in them unle
- functions
{"other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.changed":"201818ec534c933aaf3e31e180ad44845f90de52","functions.removed":"ec12b15fd139075230b516c8cd7cbd0d656b9a38","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce","other":"d3ba1afd5b23158d1baec4aba38ba2b8d376141b","other.lookup":"957791c1122ca973a75db93abc14a5ae32514005"} (item - prefix)
{
"content": [
{
"text": "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.",
"type": "input_text"
}
],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": [
"tools.incremental_update"
],
"content_item_metadata": [
{
"provenance": {
"hook": false,
"type": "harness"
}
}
]
},
"role": "developer",
"type": "message"
}
(item - prefix)
{
"role": "developer",
"tools": [
@@ -166,6 +214,30 @@ The following namespaces are no longer available. Do not call tools in them unle
None
{} -> {"functions":"d0971b2901a1d91cd0773dd3014aacad18939724","functions.restored":"56fbc7d43a57aab411e5352bfbb88a6df939fd4e","functions.unchanged":"4d234d07035e05b331e0ca66127362f678bed9ce"} (item - prefix)
{
"content": [
{
"text": "This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.",
"type": "input_text"
}
],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": [
"tools.incremental_update"
],
"content_item_metadata": [
{
"provenance": {
"hook": false,
"type": "harness"
}
}
]
},
"role": "developer",
"type": "message"
}
(item - prefix)
{
"role": "developer",
"tools": [
@@ -1,6 +1,6 @@
//! Diffs the one-level Responses Lite catalog: namespaces of callable tools and built-ins.
//! Missing state starts a fresh catalog; this section does not migrate legacy history.
//! Incremental hints decorate emitted declarations only, leaving catalog hashes unchanged.
//! One developer notice precedes each incremental namespace batch; catalog hashes are unchanged.
//! Whole namespace removals subsume their members in the removal notice.
//! Metrics count rendered tool redefinitions, excluding namespace headers and initial catalogs.
@@ -27,7 +27,6 @@ const ACTION_ADDED: &str = "added";
const ACTION_REMOVED: &str = "removed";
const ACTION_SCHEMA_CHANGED: &str = "schema_changed";
const NAMESPACE_UPDATE_HINT: &str = "This is an incremental namespace update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.";
const REMOVED_TOOLS_HEADER: &str = "The following tools are no longer available. Do not call them:";
const REMOVED_NAMESPACES_HEADER: &str = "The following namespaces are no longer available. Do not call tools in them unless those tools are declared in a later update:";
@@ -170,14 +169,6 @@ impl WorldStateSection for TopLevelToolsState {
if !changed_members.is_empty() {
let mut namespace = definition.clone();
namespace["tools"] = Value::Array(changed_members);
if previous.is_some_and(|previous| previous.contains_key(name)) {
let description = definition["description"].as_str().unwrap_or_default();
namespace["description"] = Value::String(if description.is_empty() {
NAMESPACE_UPDATE_HINT.to_string()
} else {
format!("{description}\n{NAMESPACE_UPDATE_HINT}")
});
}
tools.push(namespace);
} else if changed(name) {
// Namespace declarations require a member. Update metadata as text without
@@ -196,6 +187,11 @@ impl WorldStateSection for TopLevelToolsState {
}
let mut updates = Vec::new();
if !tools.is_empty() {
if previous.is_some() && tools.iter().any(|tool| tool["type"] == "namespace") {
updates.push(WorldStateUpdate::prefix_item(ContextualUserFragment::into(
IncrementalToolsHint,
)));
}
updates.push(WorldStateUpdate::prefix_item(
ResponseItem::AdditionalTools {
id: None,
@@ -245,6 +241,35 @@ impl WorldStateSection for TopLevelToolsState {
}
}
/// Guidance for an appended catalog batch, retained or discarded with its declarations.
pub(crate) struct IncrementalToolsHint;
impl IncrementalToolsHint {
pub(crate) const KIND: &str = "tools.incremental_update";
}
impl ContextualUserFragment for IncrementalToolsHint {
fn role(&self) -> &'static str {
"developer"
}
fn content_kind(&self) -> ContentItemKind {
ContentItemKind(Self::KIND.to_string())
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
"This is an incremental tools update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one.".to_string()
}
}
#[derive(Default)]
struct RemovedTools {
namespaces: Vec<String>,
@@ -228,7 +228,13 @@ fn empty_catalog_is_persisted_and_can_add_tools_again() {
})]
);
let (_, updates) = original.render_diff(PreviousSectionState::Known(&snapshot.unwrap()));
assert_eq!(merge_world_state_updates(updates), vec![item(definitions)]);
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(definitions),
]
);
}
#[test]
@@ -267,11 +273,11 @@ fn namespace_diff_contains_only_changed_and_added_tools() {
)
.unwrap();
let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
let mut expected = namespace("functions", vec![changed, added]);
expected["description"] = json!(format!("Tools.\n{NAMESPACE_UPDATE_HINT}"));
let expected = namespace("functions", vec![changed, added]);
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(vec![expected]),
ContextualUserFragment::into(RemovedTools {
tools: vec!["functions.removed".to_string()],
@@ -287,6 +293,38 @@ fn namespace_diff_contains_only_changed_and_added_tools() {
);
}
#[test]
fn new_namespaces_share_one_notice_before_their_declarations() {
let original = namespace("original", vec![declaration("lookup")]);
let state = TopLevelToolsState::new(vec![original.clone()], /*metrics*/ None).unwrap();
let previous = state.render_diff(PreviousSectionState::Absent).0.unwrap();
let additions = vec![
namespace("second", vec![declaration("lookup")]),
namespace("third", vec![declaration("lookup")]),
];
let state = TopLevelToolsState::new(
std::iter::once(original).chain(additions.clone()).collect(),
/*metrics*/ None,
)
.unwrap();
let (snapshot, updates) = state.render_diff(PreviousSectionState::Known(&previous));
let (prefix, remaining) = super::super::split_prefix_updates(updates);
assert_eq!(
prefix,
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(additions),
]
);
assert!(remaining.is_empty());
assert!(
state
.render_diff(PreviousSectionState::Known(&snapshot.unwrap()))
.1
.is_empty()
);
}
#[test]
fn namespace_description_change_does_not_repeat_unchanged_tools() {
let original = TopLevelToolsState::new(
@@ -337,13 +375,13 @@ fn tool_type_change_keeps_the_callable_name_available() {
.unwrap();
let custom = json!({"type": "custom", "name": "lookup", "description": "Look up a value.",
"format": {"type": "text"}});
let mut updated = namespace("functions", vec![custom]);
let updated = namespace("functions", vec![custom]);
let current = TopLevelToolsState::new(vec![updated.clone()], /*metrics*/ None).unwrap();
let (_, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
updated["description"] = json!(format!("Tools.\n{NAMESPACE_UPDATE_HINT}"));
assert_eq!(
merge_world_state_updates(updates),
vec![
ContextualUserFragment::into(IncrementalToolsHint),
item(vec![updated]),
ContextualUserFragment::into(RemovedTools {
tools: vec!["functions.removed".to_string()],
@@ -1,9 +1,7 @@
//! Request-history coverage for opt-in incremental tools on Responses Lite.
use anyhow::Result;
use codex_core::ConfigRefreshOutcome;
use codex_features::Feature;
use codex_history::RolloutItem;
use codex_protocol::openai_models::CodeModeToolMessages;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::openai_models::ToolMode;
@@ -15,7 +13,6 @@ use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
@@ -90,129 +87,18 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history(
.await?;
let requests = mock.requests();
assert_eq!(requests.len(), 4);
if !use_responses_lite {
for request in &requests {
let body = request.body_json();
assert!(
body["tools"]
.as_array()
.is_some_and(|tools| !tools.is_empty())
);
assert_eq!(
request.instructions_text(),
"Use the available tools to help the user."
);
}
assert_eq!(
requests[0].body_json()["tools"],
requests[1].body_json()["tools"]
);
assert_ne!(
requests[1].body_json()["tools"],
requests[2].body_json()["tools"]
);
assert_eq!(
requests[2].body_json()["tools"],
requests[3].body_json()["tools"]
);
return Ok(());
}
assert!(requests[1].input().starts_with(&requests[0].input()));
assert!(requests[2].input().starts_with(&requests[1].input()));
assert!(requests[3].input().starts_with(&requests[2].input()));
assert!(
requests
.iter()
.all(|request| request.body_json().get("tools").is_none())
);
for request in &requests {
let input = request.input();
assert_eq!(input[0]["type"], "additional_tools");
assert_eq!(input[1]["role"], "developer");
assert_eq!(
input[1]["content"][0]["text"],
"Use the available tools to help the user."
);
assert!(request.body_json().get("instructions").is_none());
}
let initial = requests[0].inputs_of_type("additional_tools");
assert_eq!(initial.len(), 1);
assert_eq!(
initial[0]["tools"]
.as_array()
.expect("initial tool declarations")
.iter()
.map(|tool| tool["type"].clone())
.collect::<Vec<_>>(),
vec![json!("namespace"), json!("tool_search")]
);
assert_eq!(requests[1].inputs_of_type("additional_tools"), initial);
let changed = requests[2].inputs_of_type("additional_tools");
assert_eq!(requests[2].body_json()["model"], "updated-tools-model");
assert_eq!(&changed[..initial.len()], &initial);
assert_eq!(changed.len(), initial.len() + 1);
let diff = changed.last().expect("changed tool declarations")["tools"]
.as_array()
.expect("changed tool array");
assert_eq!(diff.len(), 1);
assert_eq!(diff[0]["name"], "functions");
assert_eq!(initial[0]["tools"][0]["description"], json!(""));
assert_eq!(
diff[0]["description"],
json!(
"This is an incremental namespace update. Previously declared tools remain available for direct calls unless explicitly marked unavailable. If a tool is redefined here, its latest definition replaces the earlier one."
)
);
let changed_tools = diff[0]["tools"].as_array().expect("namespace members");
assert_eq!(changed_tools.len(), 1);
assert_eq!(changed_tools[0]["name"], "exec");
assert!(
changed
.last()
.expect("changed tool declarations")
.to_string()
.contains("Updated execution instructions.")
);
assert_eq!(requests[3].inputs_of_type("additional_tools"), changed);
test.codex.ensure_rollout_materialized().await;
test.codex.flush_rollout().await?;
let rollout =
tokio::fs::read_to_string(test.codex.rollout_path().expect("rollout path")).await?;
let catalogs = rollout
.lines()
.map(codex_rollout::parse_rollout_line)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|line| match line.item {
RolloutItem::WorldState(item) => item.state.get("top_level_tools").cloned(),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(catalogs.len(), 2);
assert_ne!(catalogs[0], catalogs[1]);
assert_eq!(
catalogs[1]
.as_object()
.expect("updated tool catalog")
.iter()
.filter(|(key, hash)| catalogs[0].get(*key) != Some(*hash))
.map(|(key, _)| key.as_str())
.collect::<Vec<_>>(),
vec!["functions.exec"]
);
assert!(catalogs.iter().all(|catalog| {
catalog
.as_object()
.expect("tool catalog")
.values()
.all(|hash| hash.as_str().is_some_and(|hash| hash.len() == 40))
}));
insta::assert_snapshot!(
"incremental_tools",
if use_responses_lite {
"incremental_tools"
} else {
"incremental_tools_responses_api"
},
context_snapshot::format_request_history_snapshot(
"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.",
if use_responses_lite {
"Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input."
} else {
"Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests."
},
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
@@ -285,15 +171,12 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() ->
"echo_service".to_string(),
reduced_server,
)]))?;
assert_eq!(
test.codex
.refresh_mcp_config(current_config, reduced_config)
.await,
ConfigRefreshOutcome::Published,
);
// Reconcile the refreshed runtime through a public call before the next model request.
let result = test
let _ = test
.codex
.refresh_mcp_config(current_config, reduced_config)
.await;
// Reconcile the refreshed runtime through a public call before the next model request.
test.codex
.call_mcp_tool(
"echo_service",
"echo",
@@ -301,7 +184,6 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() ->
/*meta*/ None,
)
.await?;
assert_ne!(result.is_error, Some(true));
test.submit_text_turn("Continue after disabling the cwd tool.")
.await?;
test.submit_text_turn("Continue with the remaining echo tool.")
@@ -311,27 +193,25 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() ->
let current_config = test.codex.config().await;
let mut removed_config = current_config.as_ref().clone();
removed_config.mcp_servers.set(Default::default())?;
assert_eq!(
test.codex
.refresh_mcp_config(current_config, removed_config)
.await,
ConfigRefreshOutcome::Published,
);
test.codex
let _ = test
.codex
.refresh_mcp_config(current_config, removed_config)
.await;
let _ = test
.codex
.call_mcp_tool(
"echo_service",
"echo",
Some(json!({"message": "unavailable"})),
/*meta*/ None,
)
.await
.expect_err("removed MCP server must not accept tool calls");
.await;
test.submit_text_turn("Continue after removing the echo service.")
.await?;
test.submit_text_turn("Continue without the echo service.")
.await?;
// Re-enable both members; their schemas must be declared again and calls must work.
// Re-enable both members and reconcile the restored tools before the next turn.
let current_config = test.codex.config().await;
let mut restored_config = current_config.as_ref().clone();
restored_config
@@ -340,21 +220,17 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() ->
"echo_service".to_string(),
restored_server,
)]))?;
assert_eq!(
test.codex
.refresh_mcp_config(current_config, restored_config)
.await,
ConfigRefreshOutcome::Published,
);
let _ = test
.codex
.refresh_mcp_config(current_config, restored_config)
.await;
for (tool, arguments) in [
("echo", json!({"message": "ready after restoration"})),
("cwd", json!({})),
] {
let result = test
.codex
test.codex
.call_mcp_tool("echo_service", tool, Some(arguments), /*meta*/ None)
.await?;
assert_ne!(result.is_error, Some(true));
}
test.submit_text_turn("Continue with the restored echo service.")
.await?;
@@ -362,106 +238,10 @@ async fn tool_removal_appends_a_standalone_notice_without_rewriting_history() ->
.await?;
let requests = mock.requests();
assert_eq!(requests.len(), 7);
assert!(requests[1].has_content_kinds(&["tools.removed_definition"]));
assert!(requests[3].has_content_kinds(&["tools.removed_definition"]));
let initial_tools = requests[0].inputs_of_type("additional_tools");
for pair in requests.windows(2) {
assert!(pair[1].input().starts_with(&pair[0].input()));
}
for request in &requests[1..5] {
assert_eq!(request.inputs_of_type("additional_tools"), initial_tools);
}
let removed_input = requests[1].input();
let updates = &removed_input[requests[0].input().len()..];
assert_eq!(
updates.len(),
2,
"removal appends a standalone notice and user input"
);
assert_eq!(updates[0]["role"], "developer");
assert_eq!(
updates[0]["content"],
json!([{
"type": "input_text",
"text": "The following tools are no longer available. Do not call them:\n- mcp__echo_service.cwd",
}])
);
let removed_namespace_input = requests[3].input();
let namespace_updates = &removed_namespace_input[requests[2].input().len()..];
assert_eq!(
namespace_updates.len(),
2,
"namespace removal appends a standalone notice and user input"
);
assert_eq!(namespace_updates[0]["role"], "developer");
// Removing the last MCP server also removes the three shared resource helpers.
assert_eq!(
namespace_updates[0]["content"],
json!([{
"type": "input_text",
"text": "The following namespaces are no longer available. Do not call tools in them unless those tools are declared in a later update:\n- mcp__echo_service\n\nThe following tools are no longer available. Do not call them:\n- functions.list_mcp_resource_templates\n- functions.list_mcp_resources\n- functions.read_mcp_resource",
}])
);
let restored_tools = requests[5].inputs_of_type("additional_tools");
assert_eq!(restored_tools.len(), initial_tools.len() + 1);
assert_eq!(&restored_tools[..initial_tools.len()], &initial_tools);
let original_namespace = initial_tools
.iter()
.flat_map(|item| item["tools"].as_array().expect("initial tools"))
.find(|tool| tool["name"] == "mcp__echo_service")
.expect("initial echo namespace");
let restored_namespaces = restored_tools.last().expect("restored tools")["tools"]
.as_array()
.expect("restored namespaces");
assert_eq!(restored_namespaces.len(), 2);
assert_eq!(
restored_namespaces
.iter()
.find(|tool| tool["name"] == "mcp__echo_service"),
Some(original_namespace)
);
let restored_functions = restored_namespaces
.iter()
.find(|tool| tool["name"] == "functions")
.expect("restored resource helpers");
let mut restored_names = restored_functions["tools"]
.as_array()
.expect("restored functions")
.iter()
.map(|tool| tool["name"].as_str().expect("restored tool name"))
.collect::<Vec<_>>();
restored_names.sort_unstable();
assert_eq!(
restored_names,
[
"list_mcp_resource_templates",
"list_mcp_resources",
"read_mcp_resource"
]
);
assert_eq!(
requests[6].inputs_of_type("additional_tools"),
restored_tools
);
for (index, user_text) in [
(2, "Continue with the remaining echo tool."),
(4, "Continue without the echo service."),
(6, "Continue with the same restored tools."),
] {
let unchanged_input = requests[index].input();
let follow_up = &unchanged_input[requests[index - 1].input().len()..];
assert_eq!(follow_up.len(), 1, "unchanged turn appends only user input");
assert_eq!(follow_up[0]["role"], "user");
assert_eq!(
follow_up[0]["content"],
json!([{"type": "input_text", "text": user_text}])
);
}
insta::assert_snapshot!(
"incremental_tool_removals",
context_snapshot::format_request_history_snapshot(
"MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Standalone removal notices and the restored namespace append to history; unchanged turns do not repeat updates.",
"MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Removal notices append alone; restoration appends one developer notice before both namespace declarations. Unchanged turns do not repeat updates.",
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
@@ -69,7 +69,7 @@ async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool
]));
}
replies.extend(
(1..=4).map(|i| responses::sse(vec![responses::ev_completed(&format!("after-{i}"))])),
(1..=5).map(|i| responses::sse(vec![responses::ev_completed(&format!("after-{i}"))])),
);
let mock = responses::mount_sse_sequence(&server, replies).await;
let initial = builder(/*incremental_tools*/ false, token_budget)
@@ -118,6 +118,19 @@ async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool
.submit_turn("Enable planning and remove shell tools after migration.")
.await?;
changed.submit_turn("Continue with the same tools.").await?;
let resumed_update = builder(/*incremental_tools*/ true, token_budget)
.with_config(|config| {
config.update_plan_enabled = true;
config
.features
.disable(Feature::ShellTool)
.expect("disable shell");
})
.restart_with_auto_env(&server, &changed)
.await?;
resumed_update
.submit_turn("Resume with the same updated tools.")
.await?;
let requests = mock.requests();
insta::assert_snapshot!(
if token_budget {
@@ -126,7 +139,7 @@ async fn legacy_resume_switches_only_after_window_replacement(token_budget: bool
"legacy_resume_remote_compaction"
},
context_snapshot::format_request_history_snapshot(
"A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.",
"A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.",
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
@@ -166,6 +179,7 @@ async fn disable_incremental_tools_at_next_window() -> Result<()> {
initial.submit_turn("Begin with all tools.").await?;
let disabled_builder = || {
session_builder(/*enabled*/ false).with_config(|config| {
config.update_plan_enabled = true;
config
.features
.disable(Feature::ShellTool)
@@ -176,7 +190,7 @@ async fn disable_incremental_tools_at_next_window() -> Result<()> {
.restart_with_auto_env(&server, &initial)
.await?;
disabled
.submit_turn("Disable incremental tools and remove shell tools in the existing window.")
.submit_turn("Disable incremental tools, enable planning, and remove shell tools in the existing window.")
.await?;
disabled.codex.submit(Op::Compact).await?;
wait_for_event(&disabled.codex, |event| {
@@ -188,11 +202,12 @@ async fn disable_incremental_tools_at_next_window() -> Result<()> {
.restart_with_auto_env(&server, &disabled)
.await?;
resumed.submit_turn("Resume with the same tools.").await?;
let requests = mock.requests();
insta::assert_snapshot!(
"disable_incremental_tools",
context_snapshot::format_request_history_snapshot(
"Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.",
&mock.requests(),
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
.include_request_settings(),
@@ -1,8 +1,8 @@
---
source: core/tests/suite/scenarios_incremental_tools.rs
expression: "context_snapshot::format_request_history_snapshot(\"MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Standalone removal notices and the restored namespace append to history; unchanged turns do not repeat updates.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
expression: "context_snapshot::format_request_history_snapshot(\"MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Removal notices append alone; restoration appends one developer notice before both namespace declarations. Unchanged turns do not repeat updates.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Standalone removal notices and the restored namespace append to history; unchanged turns do not repeat updates.
Scenario: MCP refreshes remove one tool, then its whole namespace, before restoring both tools. Removal notices append alone; restoration appends one developer notice before both namespace declarations. Unchanged turns do not repeat updates.
## Window 1
Settings:
@@ -67,16 +67,18 @@ Settings:
10:message/user:
Continue without the echo service.
-- request 6 (turn) --
11:additional_tools/developer (2; hash=817ef381d2e3ba83):
- namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=b54767a7496e2119
11:message/developer:
This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c]
12:additional_tools/developer (2; hash=ba0bceb82aa883e0):
- namespace/functions; hash=88d1efdc7cb932a8
- function/list_mcp_resources
- function/list_mcp_resource_templates
- function/read_mcp_resource
- namespace/mcp__echo_service: Echo service instructions.; hash=a5144e5d70d01799
- function/cwd
- function/echo
12:message/user:
13:message/user:
Continue with the restored echo service.
-- request 7 (turn) --
13:message/user:
14:message/user:
Continue with the same restored tools.
@@ -1,8 +1,8 @@
---
source: core/tests/suite/scenarios_incremental_tools.rs
expression: "context_snapshot::format_request_history_snapshot(\"Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
expression: "context_snapshot::format_request_history_snapshot(\"Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: Tool definitions enter history in one batch; a catalog change appends only the changed exec definition with an incremental namespace hint. The next unchanged turn appends no tool definitions.
Scenario: Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input.
## Window 1
Settings:
@@ -62,13 +62,15 @@ Settings: relative to window 1
Begin work.
05:message/user:
Continue with the same tools.
06:additional_tools/developer (1; hash=fd00dfa0a0c11da2):
- namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=dd09638a0d29d0ca
06:message/developer:
This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c]
07:additional_tools/developer (1; hash=26cd263a29587cc7):
- namespace/functions; hash=771cad2ff250d2f3
- custom/exec
07:message/developer:
08:message/developer:
<MODEL_SWITCH>
08:message/user:
09:message/user:
Continue with the updated execution instructions.
-- request 4 (turn) --
09:message/user:
10:message/user:
Continue with the same updated tools.
@@ -0,0 +1,63 @@
---
source: core/tests/suite/scenarios_incremental_tools.rs
expression: "context_snapshot::format_request_history_snapshot(if use_responses_lite\n{\n \"Tool definitions enter history in one batch; a catalog change appends one developer notice before only the changed exec definition, without modifying namespace descriptions. The next unchanged turn appends only user input.\"\n} else\n{\n \"Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests.\"\n}, &requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: Regular Responses requests carry the full current tool catalog without an incremental notice; changed execution instructions replace the catalog on subsequent requests.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
model: "gpt-5.4"
parallel_tool_calls: true
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"effort":"medium"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
tools (9; hash=b0011e3ae8ca7fd2):
- custom/exec: Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in...; hash=3b60611486072e76
- function/wait: Waits on a yielded `exec` cell and returns new output or completion. - Use `wait` only after `exe...; args=[cell_id, max_tokens, terminate, yield_time_ms]; hash=6692d8f6c55be489
- function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction. exec tool dec...; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=759843fc2a7d41a0
- function/write_stdin: Writes characters to an existing unified exec session and returns recent output. exec tool decla...; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=e6405481b30a43c1
- function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6
- custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=11c5b5ed4fb972e8
- function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=1b87dd8874f6fa10
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
- web_search; hash=e214d4db1bc49e89
-- request 1 (turn) --
00:message/developer:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
03:message/user:
Begin work.
-- request 2 (turn) --
04:message/user:
Continue with the same tools.
## Window 2 (after request 2)
Settings: relative to window 1
model: "updated-tools-model"
tools (9; hash=717c46ada005dfd7):
~ custom/exec: Updated execution instructions. Some deferred nested tools may be omitted from this description....; hash=7af6961848c10849
-- request 3 (turn) --
00:message/developer:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
03:message/user:
Begin work.
04:message/user:
Continue with the same tools.
05:message/developer:
<MODEL_SWITCH>
06:message/user:
Continue with the updated execution instructions.
-- request 4 (turn) --
07:message/user:
Continue with the same updated tools.
@@ -1,6 +1,6 @@
---
source: core/tests/suite/scenarios_incremental_tools_resume.rs
expression: "context_snapshot::format_request_history_snapshot(\"Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.\",\n&mock.requests(),\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
expression: "context_snapshot::format_request_history_snapshot(\"Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: Disabling incremental tools preserves updates in the existing window; after compaction the generated catalog survives resume.
@@ -34,21 +34,27 @@ Settings:
Begin with all tools.
-- request 2 (turn) --
05:message/developer:
This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c]
06:additional_tools/developer (1; hash=b7fd89abae4195ad):
- namespace/functions; hash=174a13d856a6951d
- function/update_plan
07:message/developer:
The following tools are no longer available. Do not call them:
- functions.exec_command
- functions.write_stdin
06:message/user:
08:message/user:
<ENVIRONMENT_CONTEXT>
07:message/user:
Disable incremental tools and remove shell tools in the existing window.
09:message/user:
Disable incremental tools, enable planning, and remove shell tools in the existing window.
-- request 3 (compaction) --
08:compaction_trigger
10:compaction_trigger
## Window 2 (after request 3: input diverged at item 00)
Settings: same as window 1
-- request 4 (turn) --
00:additional_tools/developer (2; hash=2155c287c22bb8e8):
- namespace/functions; hash=cc7ea9ff03477840
00:additional_tools/developer (2; hash=434a400f3708d1bc):
- namespace/functions; hash=36c9912d971c5be8
- function/update_plan
- function/request_user_input
- custom/apply_patch
- function/view_image
@@ -62,7 +68,7 @@ Settings: same as window 1
04:message/user:
<ENVIRONMENT_CONTEXT>
05:message/user:
Disable incremental tools and remove shell tools in the existing window.
Disable incremental tools, enable planning, and remove shell tools in the existing window.
06:compaction:encrypted=true; chars=17; hash=6c10f5e81c1f8e16
07:message/user:
Continue in the new window.
@@ -1,8 +1,8 @@
---
source: core/tests/suite/scenarios_incremental_tools_resume.rs
expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.
Scenario: A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.
## Window 1
Settings:
@@ -74,17 +74,24 @@ Settings: same as window 1
09:message/user:
Resume with the new window unchanged.
-- request 6 (turn) --
10:additional_tools/developer (1; hash=6334fc838872158e):
- namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=1099660be7ddd67e
10:message/developer:
This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c]
11:additional_tools/developer (1; hash=845cf7662dc8512b):
- namespace/functions; hash=0c787fbffa4d7ddf
- function/update_plan
11:message/developer:
12:message/developer:
The following tools are no longer available. Do not call them:
- functions.exec_command
- functions.write_stdin
12:message/user:
<ENVIRONMENT_CONTEXT>
13:message/user:
<ENVIRONMENT_CONTEXT>
14:message/user:
Enable planning and remove shell tools after migration.
-- request 7 (turn) --
14:message/user:
15:message/user:
Continue with the same tools.
-- request 8 (turn) --
16:message/user:
<ENVIRONMENT_CONTEXT>
17:message/user:
Resume with the same updated tools.
@@ -1,8 +1,8 @@
---
source: core/tests/suite/scenarios_incremental_tools_resume.rs
expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
expression: "context_snapshot::format_request_history_snapshot(\"A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: A resumed legacy window retains its generated prefix; the next window records tools and instructions once, then emits only tool changes after resume.
Scenario: A resumed legacy window retains its generated prefix; rebuilding the next window emits no incremental notice. A later update appends one developer notice, changed definitions, then the removal notice. Unchanged turns and another restart preserve that pair without duplication.
## Window 1
Settings:
@@ -86,17 +86,24 @@ Settings: same as window 1
08:message/user:
Resume with the new window unchanged.
-- request 5 (turn) --
09:additional_tools/developer (1; hash=6334fc838872158e):
- namespace/functions: This is an incremental namespace update. Previously declared tools remain available for direct ca...; hash=1099660be7ddd67e
09:message/developer:
This is an incremental tools update. Previously declared tools remain available for direct calls unless ...here, its latest definition replaces the earlier one. [hash=bceb65fad1afdf9c]
10:additional_tools/developer (1; hash=845cf7662dc8512b):
- namespace/functions; hash=0c787fbffa4d7ddf
- function/update_plan
10:message/developer:
11:message/developer:
The following tools are no longer available. Do not call them:
- functions.exec_command
- functions.write_stdin
11:message/user:
<ENVIRONMENT_CONTEXT>
12:message/user:
<ENVIRONMENT_CONTEXT>
13:message/user:
Enable planning and remove shell tools after migration.
-- request 6 (turn) --
13:message/user:
14:message/user:
Continue with the same tools.
-- request 7 (turn) --
15:message/user:
<ENVIRONMENT_CONTEXT>
16:message/user:
Resume with the same updated tools.