Add Responses API client metadata patches for turn start and steer (#54053)

## Why

Clients need a way to update individual turn metadata entries without replacing the existing client metadata map.

## What changed

Add the experimental `responsesapiClientMetadataPatch` field to `turn/start` and `turn/steer`. Patches add or overwrite entries while retaining other keys and apply after `responsesapiClientMetadata` when both are supplied. The existing field retains replacement semantics, including clearing unprotected entries with an empty map.

Continue protecting reserved metadata keys and preserving targeted `misalignment_override` continuations across updates.

## Testing

Extend the app-server metadata integration test to cover replacement and patch steering, patch precedence at turn start, preservation of unrelated entries during patches, and protection of targeted continuations.

GitOrigin-RevId: 8c4da3ba1b87329da20b5295846bdcf3a88126de
This commit is contained in:
marksteinbrick-oai
2026-10-14 17:39:43 +00:00
committed by copyberry
parent 9671d20fc8
commit 2b21129c6b
18 changed files with 143 additions and 54 deletions
+1
View File
@@ -594,6 +594,7 @@ fn sample_turn_steer_request() -> ClientRequest {
client_user_message_id: None,
input: Vec::new(),
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
},
}
@@ -55,6 +55,7 @@ fn sample_turn_steer_request(
},
],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
},
}
@@ -5089,6 +5089,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
root_turn_id: None,
tool_output: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
environments: None,
cwd: None,
@@ -198,6 +198,7 @@ pub struct TurnStartParams {
#[ts(optional = nullable)]
pub tool_output: Option<Box<TurnToolOutput>>,
/// Optional metadata to enrich Codex's ResponsesAPI turn metadata.
/// Replaces unprotected client-provided entries when supplied; an empty map clears them.
///
/// Entries are flattened into the JSON string sent as
/// `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests.
@@ -207,6 +208,11 @@ pub struct TurnStartParams {
#[experimental("turn/start.responsesapiClientMetadata")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Merge entries into the ResponsesAPI turn metadata without removing existing entries.
/// Applied after `responsesapiClientMetadata` if both are provided; reserved keys remain protected.
#[experimental("turn/start.responsesapiClientMetadataPatch")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata_patch: Option<HashMap<String, String>>,
/// Optional client-provided context fragments keyed by an opaque source identifier.
#[experimental("turn/start.additionalContext")]
#[ts(optional = nullable)]
@@ -314,6 +320,7 @@ pub struct TurnSteerParams {
pub client_user_message_id: Option<String>,
pub input: Vec<UserInput>,
/// Optional metadata to enrich Codex's ResponsesAPI turn metadata.
/// Replaces unprotected client-provided entries when supplied; an empty map clears them.
///
/// Entries are flattened into the JSON string sent as
/// `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests.
@@ -323,6 +330,11 @@ pub struct TurnSteerParams {
#[experimental("turn/steer.responsesapiClientMetadata")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Merge entries into the ResponsesAPI turn metadata without removing existing entries.
/// Applied after `responsesapiClientMetadata` if both are provided; reserved keys remain protected.
#[experimental("turn/steer.responsesapiClientMetadataPatch")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata_patch: Option<HashMap<String, String>>,
/// Optional client-provided context fragments keyed by an opaque source identifier.
#[experimental("turn/steer.additionalContext")]
#[ts(optional = nullable)]
@@ -688,6 +688,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
root_turn_id: None,
tool_output: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
cwd: None,
runtime_workspace_roots: None,
@@ -673,6 +673,7 @@ impl TurnRequestProcessor {
})
.with_additional_context(additional_context)
.with_responses_metadata(params.responsesapi_client_metadata)
.with_responses_metadata_patch(params.responsesapi_client_metadata_patch)
.with_trace(self.request_trace_context(&request_id).await),
)
.await
@@ -1088,7 +1089,8 @@ impl TurnRequestProcessor {
client_id: params.client_user_message_id,
})
.with_additional_context(additional_context)
.with_responses_metadata(params.responsesapi_client_metadata),
.with_responses_metadata(params.responsesapi_client_metadata)
.with_responses_metadata_patch(params.responsesapi_client_metadata_patch),
params.expected_turn_id,
)
.await
@@ -449,8 +449,12 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2()
Ok(())
}
#[test_case::test_case(false; "replacement")]
#[test_case::test_case(true; "patch")]
#[tokio::test]
async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() -> Result<()> {
async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2(
patch_steer: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let codex_home = TempDir::new()?;
@@ -489,7 +493,8 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
let start_metadata = HashMap::from([
("fiber_run_id".to_string(), "fiber-start-123".to_string()),
("source".to_string(), "initial-source".to_string()),
("source".to_string(), "snapshot-source".to_string()),
("start_only".to_string(), "start-value".to_string()),
(
"misalignment_override".to_string(),
r#"{"review_target":"RB._~:-opaque"}"#.to_string(),
@@ -504,7 +509,11 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
text_elements: Vec::new(),
}],
turn_trigger: Some("user".to_string()),
responsesapi_client_metadata: Some(start_metadata.clone()),
responsesapi_client_metadata: Some(start_metadata),
responsesapi_client_metadata_patch: Some(HashMap::from([(
"source".to_string(),
"initial-source".to_string(),
)])),
..Default::default()
})
.await?;
@@ -528,6 +537,11 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
r#"{"review_target":"RC"}"#.to_string(),
),
]);
let (responsesapi_client_metadata, responsesapi_client_metadata_patch) = if patch_steer {
(None, Some(steer_metadata))
} else {
(Some(steer_metadata), None)
};
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id.clone(),
@@ -536,7 +550,8 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
text: "Focus on the failure".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: Some(steer_metadata.clone()),
responsesapi_client_metadata,
responsesapi_client_metadata_patch,
additional_context: None,
expected_turn_id: turn_id.clone(),
})
@@ -578,6 +593,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
assert_eq!(second_metadata["turn_id"].as_str(), Some(turn_id.as_str()));
assert_eq!(second_metadata["turn_trigger"].as_str(), Some("user"));
assert_eq!(second_metadata["source"].as_str(), Some("steer-source"));
assert_eq!(
second_metadata["start_only"].as_str(),
patch_steer.then_some("start-value")
);
assert_eq!(
second_metadata["misalignment_override"],
first_metadata["misalignment_override"],
@@ -1316,6 +1316,7 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa
additional_context: None,
client_user_message_id: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
})
.await?;
let rejected = timeout(
@@ -1348,6 +1349,7 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa
additional_context: None,
client_user_message_id: Some("accepted-steer".to_string()),
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
})
.await?;
let accepted: TurnSteerResponse =
@@ -3406,6 +3406,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
root_turn_id: None,
tool_output: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
cwd: Some(first_cwd.clone()),
runtime_workspace_roots: None,
@@ -3461,6 +3462,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
root_turn_id: None,
tool_output: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
cwd: None,
runtime_workspace_roots: None,
@@ -4682,6 +4684,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected(
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: "any-active-turn".to_string(),
})
@@ -72,6 +72,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> {
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: "turn-does-not-exist".to_string(),
})
@@ -185,6 +186,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> {
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: turn.id.clone(),
})
@@ -300,6 +302,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: turn.id.clone(),
},
@@ -435,6 +438,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu
client_user_message_id: None,
input: Vec::new(),
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context,
expected_turn_id: turn.id,
})
+12 -7
View File
@@ -41,6 +41,7 @@ use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::turn_input::ResponsesApiClientMetadataUpdate;
use codex_utils_path_uri::PathUri;
use core_test_support::hooks::trusted_config_layer_stack;
use core_test_support::responses::ev_assistant_message;
@@ -1087,15 +1088,19 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() {
let (_, turn_context) = make_session_and_context().await;
let turn_context = Arc::new(turn_context);
let step_context = StepContext::for_test(Arc::clone(&turn_context));
let client_metadata = HashMap::from([
(
"node_repl_auto_review_required".to_string(),
"true".to_string(),
),
("node_repl_disabled".to_string(), "true".to_string()),
]);
turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(HashMap::from([
(
"node_repl_auto_review_required".to_string(),
"true".to_string(),
),
("node_repl_disabled".to_string(), "true".to_string()),
]));
.update_responsesapi_client_metadata(ResponsesApiClientMetadataUpdate {
replacement: Some(client_metadata),
..Default::default()
});
let expected_turn_metadata = expected_mcp_turn_metadata(&turn_context);
let meta = build_mcp_tool_call_request_meta(
+12 -18
View File
@@ -44,6 +44,7 @@ use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::turn_input::AnnotatedResponseItem;
use codex_protocol::turn_input::NotSubmittedReason;
use codex_protocol::turn_input::ResponsesApiClientMetadataUpdate;
use codex_protocol::turn_input::TurnInput as SubmittedTurnInput;
use codex_protocol::turn_input::TurnInputMode;
use codex_protocol::turn_input::TurnInputRequest;
@@ -52,7 +53,6 @@ use codex_protocol::turn_input::TurnStartOptions;
use codex_protocol::user_input::UserInput;
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
@@ -418,11 +418,9 @@ async fn start_or_steer(
else {
unreachable!("explicit user input can enter Plan mode");
};
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
turn_context
.turn_metadata_state
.update_responsesapi_client_metadata(responsesapi_client_metadata);
session
.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
@@ -572,11 +570,9 @@ async fn start_if_idle(
return Err(error);
}
};
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
turn_context
.turn_metadata_state
.update_responsesapi_client_metadata(responsesapi_client_metadata);
session
.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
@@ -807,7 +803,7 @@ impl Session {
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
required_final_output_json_schema: Option<&Value>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
responsesapi_client_metadata: ResponsesApiClientMetadataUpdate,
origin: UserInputOrigin,
) -> Result<(String, String), NotSubmittedReason> {
let mut active = self.active_turn.lock().await;
@@ -856,12 +852,10 @@ impl Session {
}
let mut pending_input = merge_additional_context_input(self, additional_context).await;
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
active_task
.turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
active_task
.turn_context
.turn_metadata_state
.update_responsesapi_client_metadata(responsesapi_client_metadata);
let input = match input {
SubmittedTurnInput::UserInput { content, client_id } => {
+16 -8
View File
@@ -1,5 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
@@ -38,6 +37,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::turn_input::ResponsesApiClientMetadataUpdate;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_git_discovery::GitRootDiscovery;
@@ -383,23 +383,31 @@ impl TurnMetadataState {
self.root_turn_id.get().cloned()
}
pub(crate) fn set_responsesapi_client_metadata(
pub(crate) fn update_responsesapi_client_metadata(
&self,
responsesapi_client_metadata: HashMap<String, String>,
update: ResponsesApiClientMetadataUpdate,
) {
let mut metadata = filter_extra_metadata(responsesapi_client_metadata);
let replacement = update.replacement.map(filter_extra_metadata);
let patch = update.patch.map(filter_extra_metadata).unwrap_or_default();
if replacement.is_none() && patch.is_empty() {
return;
}
let mut current = self
.responsesapi_client_metadata
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(continuation) = current
let continuation = current
.get("misalignment_override")
.filter(|value| is_targeted_continuation(value))
{
.cloned();
if let Some(replacement) = replacement {
*current = replacement;
}
current.extend(patch);
if let Some(continuation) = continuation {
// Steering may update other metadata, but cannot replace the reviewed block.
metadata.insert("misalignment_override".to_string(), continuation.clone());
current.insert("misalignment_override".to_string(), continuation);
}
*current = metadata;
}
pub(crate) fn set_responses_api_metadata(
+27 -12
View File
@@ -684,7 +684,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
/*auto_review_enabled*/ false,
&model_info_from_slug("gpt-5.4"),
);
state.set_responsesapi_client_metadata(HashMap::from([
let client_metadata = HashMap::from([
(
LEGACY_CODE_MODE_TOOL_NAMES_KEY.to_string(),
"client-supplied".to_string(),
@@ -722,7 +722,11 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
"true".to_string(),
),
(NODE_REPL_DISABLED_KEY.to_string(), "true".to_string()),
]));
]);
state.update_responsesapi_client_metadata(ResponsesApiClientMetadataUpdate {
replacement: Some(client_metadata),
..Default::default()
});
let header = test_turn_metadata_header(&state);
let json: Value = serde_json::from_str(&header).expect("json");
@@ -792,7 +796,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
state.set_parent_turn_id("parent-turn-a".to_string());
state.set_root_turn_id("root-turn-a".to_string());
state.set_turn_trigger("goal".to_string());
state.set_responsesapi_client_metadata(HashMap::from([
let client_metadata = HashMap::from([
(
"codex_security_surface".to_string(),
"client-supplied".to_string(),
@@ -861,7 +865,11 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
"turn_started_at_unix_ms".to_string(),
"client-supplied".to_string(),
),
]));
]);
state.update_responsesapi_client_metadata(ResponsesApiClientMetadataUpdate {
replacement: Some(client_metadata),
..Default::default()
});
state.set_turn_started_at_unix_ms(/*turn_started_at_unix_ms*/ 1_700_000_000_123);
let header = test_turn_metadata_header(&state);
assert!(header.is_ascii());
@@ -996,12 +1004,15 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
.into_iter()
.collect(),
);
state.set_responsesapi_client_metadata(
client
.map(|source| ("source".to_string(), source))
.into_iter()
.collect(),
);
state.update_responsesapi_client_metadata(ResponsesApiClientMetadataUpdate {
replacement: Some(
client
.map(|source| ("source".to_string(), source))
.into_iter()
.collect(),
),
..Default::default()
});
assert_eq!(state.codex_turn_source(), expected);
}
}
@@ -1071,13 +1082,17 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests(
serde_json::from_str(&test_turn_responses_metadata_json(&state, "thread-a:1"))
.expect("json");
assert!(ordinary_json.get("misalignment_override").is_none());
state.set_responsesapi_client_metadata(HashMap::from([
let client_metadata = HashMap::from([
("compaction".to_string(), "client-supplied".to_string()),
(
"misalignment_override".to_string(),
continuation.to_string(),
),
]));
]);
state.update_responsesapi_client_metadata(ResponsesApiClientMetadataUpdate {
replacement: Some(client_metadata),
..Default::default()
});
let mcp_metadata = state
.current_meta_value_for_mcp_request(test_mcp_turn_metadata_context())
+1
View File
@@ -1240,6 +1240,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
input: items.into_iter().map(Into::into).collect(),
tool_output: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
environments: None,
cwd: Some(default_cwd),
+22 -4
View File
@@ -66,10 +66,19 @@ pub struct TurnInputRequest {
pub thread_settings: ThreadSettingsOverrides,
pub start: TurnStartOptions,
pub additional_context: BTreeMap<String, AdditionalContextEntry>,
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
pub responsesapi_client_metadata: ResponsesApiClientMetadataUpdate,
pub trace: Option<W3cTraceContext>,
}
/// An optional replacement followed by a patch to the turn's client metadata.
#[derive(Clone, Debug, Default)]
pub struct ResponsesApiClientMetadataUpdate {
/// Replaces the existing map, including when empty.
pub replacement: Option<HashMap<String, String>>,
/// Adds or overwrites keys without removing other entries.
pub patch: Option<HashMap<String, String>>,
}
/// Attribution retained across recovery, sleep, and model-context compaction.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct TurnAttribution {
@@ -114,7 +123,7 @@ impl TurnInputRequest {
thread_settings: ThreadSettingsOverrides::default(),
start: TurnStartOptions::default(),
additional_context: BTreeMap::new(),
responsesapi_client_metadata: None,
responsesapi_client_metadata: ResponsesApiClientMetadataUpdate::default(),
trace: None,
}
}
@@ -153,12 +162,21 @@ impl TurnInputRequest {
self
}
/// Responses metadata attached whether this request starts or steers.
/// Replaces Responses client metadata whether this request starts or steers.
pub fn with_responses_metadata(
mut self,
responsesapi_client_metadata: Option<HashMap<String, String>>,
) -> Self {
self.responsesapi_client_metadata = responsesapi_client_metadata;
self.responsesapi_client_metadata.replacement = responsesapi_client_metadata;
self
}
/// Adds or overwrites Responses client metadata entries after any replacement.
pub fn with_responses_metadata_patch(
mut self,
responsesapi_client_metadata_patch: Option<HashMap<String, String>>,
) -> Self {
self.responsesapi_client_metadata.patch = responsesapi_client_metadata_patch;
self
}
+2
View File
@@ -1458,6 +1458,7 @@ impl AppServerSession {
input: items,
tool_output: None,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
environments: None,
cwd: Some(cwd),
@@ -1524,6 +1525,7 @@ impl AppServerSession {
client_user_message_id: Some(client_user_message_id),
input: items,
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: turn_id,
},