Forward turn metadata in standalone web search (#32835)

## What changed

- Add optional serialized Codex turn metadata to extension tool calls.
- Populate it from the current turn context and send it as the
  `x-codex-turn-metadata` header on standalone web search requests.

## Testing

- Extend the app-server web search integration test to verify that client
  metadata, including the model ID, reaches the standalone search request.

GitOrigin-RevId: c0950108d89a1d317ac94ccd23efcee72fffc6f0
This commit is contained in:
XMLIU-oai
2026-07-13 16:47:26 +00:00
committed by copyberry
parent 2e156cbe31
commit 19a000259d
7 changed files with 80 additions and 8 deletions

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
@@ -44,6 +45,18 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn standalone_web_search_round_trips_output() -> Result<()> {
let call_id = "web-run-1";
let expected_model_id = "model-id-from-search-context";
let search_context = json!({
"telemetry_attributes": {
"model_id": expected_model_id,
"model_slug": "mock-model",
}
})
.to_string();
let client_metadata = HashMap::from([(
"mcp_request_meta".to_string(),
json!({ "openai/search_context": search_context }).to_string(),
)]);
let server = responses::start_mock_server().await;
mount_search_response(&server).await;
@@ -105,6 +118,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
text: "Search the web".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: Some(client_metadata.clone()),
..Default::default()
})
.await?;
@@ -144,7 +158,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
"standalone web search should replace hosted web search"
);
let search_body = search_request_body(&server).await?;
let search_request = search_request(&server).await?;
let search_body = search_request
.body_json::<Value>()
.context("search request body should be JSON")?;
assert_eq!(search_body["model"], json!("mock-model"));
assert_eq!(
search_body["commands"],
@@ -169,6 +186,30 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
"content": [{"type": "input_text", "text": "Search the web"}],
}))
);
let turn_metadata_header = search_request
.headers
.get("x-codex-turn-metadata")
.context("standalone search should include x-codex-turn-metadata")?
.to_str()
.context("x-codex-turn-metadata should be valid ASCII")?;
let turn_metadata: Value = serde_json::from_str(turn_metadata_header)
.context("x-codex-turn-metadata should be valid JSON")?;
let mcp_request_meta = turn_metadata["mcp_request_meta"]
.as_str()
.context("mcp_request_meta should be a JSON string")?;
let mcp_request_meta: Value = serde_json::from_str(mcp_request_meta)
.context("mcp_request_meta should contain valid JSON")?;
let search_context = mcp_request_meta["openai/search_context"]
.as_str()
.context("openai/search_context should be a JSON string")?;
let search_context: Value = serde_json::from_str(search_context)
.context("openai/search_context should contain valid JSON")?;
assert_eq!(
search_context
.pointer("/telemetry_attributes/model_id")
.and_then(Value::as_str),
Some(expected_model_id)
);
assert_eq!(
responses::strip_metadata_from_json(requests[1].function_call_output(call_id)),
@@ -285,16 +326,15 @@ fn has_hosted_web_search(body: &Value) -> bool {
})
}
async fn search_request_body(server: &MockServer) -> Result<Value> {
server
async fn search_request(server: &MockServer) -> Result<wiremock::Request> {
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?
.context("failed to fetch received requests")?;
requests
.into_iter()
.find(|request| request.url.path() == "/api/codex/alpha/search")
.context("expected standalone search request")?
.body_json()
.context("search request body should be JSON")
.context("expected standalone search request")
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {

View File

@@ -13,6 +13,7 @@ use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use codex_tools::TurnItemEmissionFuture;
use codex_tools::TurnItemEmitter;
use codex_utils_string::to_ascii_json_string;
use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
@@ -22,6 +23,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use crate::turn_metadata::McpTurnMetadataContext;
pub(crate) struct ExtensionToolAdapter(Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);
@@ -114,6 +116,14 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
let conversation_history =
ConversationHistory::new(invocation.session.clone_history().await.into_raw_items());
let codex_turn_metadata = invocation
.turn
.turn_metadata_state
.current_meta_value_for_mcp_request(McpTurnMetadataContext {
model: invocation.turn.model_info.slug.as_str(),
reasoning_effort: invocation.turn.effective_reasoning_effort(),
})
.and_then(|metadata| to_ascii_json_string(&metadata).ok());
let mut environments =
Vec::with_capacity(invocation.step_context.environments.turn_environments.len());
for environment in &invocation.step_context.environments.turn_environments {
@@ -146,6 +156,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.clone(),
model: invocation.turn.model_info.slug.clone(),
codex_turn_metadata,
truncation_policy: invocation.turn.model_info.truncation_policy.into(),
conversation_history,
turn_item_emitter: Arc::new(CoreTurnItemEmitter {

View File

@@ -1343,6 +1343,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To
call_id: call_id.to_string(),
tool_name: codex_extension_api::ToolName::plain(tool_name),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),

View File

@@ -212,6 +212,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() {
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -256,6 +257,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() {
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -301,6 +303,7 @@ async fn read_tool_reads_memory_file() {
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::READ_TOOL_NAME),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -349,6 +352,7 @@ async fn search_tool_accepts_multiple_queries() {
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -423,6 +427,7 @@ async fn search_tool_accepts_windowed_all_match_mode() {
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -477,6 +482,7 @@ async fn search_tool_rejects_legacy_single_query() {
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),

View File

@@ -438,6 +438,7 @@ async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResu
call_id: "call-1".to_string(),
tool_name: list_tool.tool_name(),
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1_024),
conversation_history: ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),

View File

@@ -4,6 +4,7 @@ use codex_api::SearchCommands;
use codex_api::SearchQuery;
use codex_api::SearchRequest;
use codex_api::SearchSettings;
use codex_core::X_CODEX_TURN_METADATA_HEADER;
use codex_core::web_search_action_detail;
use codex_extension_api::ExtensionTurnItem;
use codex_extension_api::FunctionCallError;
@@ -28,6 +29,7 @@ use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolExposure;
use codex_tools::default_namespace_description;
use http::HeaderMap;
use http::HeaderValue;
use url::Url;
use crate::history::recent_input;
@@ -113,6 +115,12 @@ impl WebSearchTool {
u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX),
),
};
let mut extra_headers = HeaderMap::new();
if let Some(turn_metadata) = call.codex_turn_metadata.as_deref()
&& let Ok(header_value) = HeaderValue::from_str(turn_metadata)
{
extra_headers.insert(X_CODEX_TURN_METADATA_HEADER, header_value);
}
call.turn_item_emitter
.emit_started(extension_turn_item(
WebSearchItem {
@@ -126,7 +134,7 @@ impl WebSearchTool {
))
.await;
let response = client
.search(&request, HeaderMap::new())
.search(&request, extra_headers)
.await
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
let legacy_action = match &command_action {

View File

@@ -92,6 +92,7 @@ pub struct ToolCall {
pub call_id: String,
pub tool_name: ToolName,
pub model: String,
pub codex_turn_metadata: Option<String>,
pub truncation_policy: TruncationPolicy,
pub conversation_history: ConversationHistory,
pub turn_item_emitter: Arc<dyn TurnItemEmitter>,
@@ -106,6 +107,10 @@ impl std::fmt::Debug for ToolCall {
.field("call_id", &self.call_id)
.field("tool_name", &self.tool_name)
.field("model", &self.model)
.field(
"has_codex_turn_metadata",
&self.codex_turn_metadata.is_some(),
)
.field("truncation_policy", &self.truncation_policy)
.field("conversation_history", &self.conversation_history)
.field("turn_item_emitter", &"<host turn item emitter>")