From f76eb1d07cfbd5fe35d1dfb001defa8f689d0172 Mon Sep 17 00:00:00 2001 From: Alexi Christakis <167903946+alexi-openai@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:54:07 -0700 Subject: [PATCH] Decode hosted web search sources for clients --- codex-rs/analytics/src/reducer.rs | 2 +- .../src/protocol/thread_history.rs | 3 + .../src/protocol/v2/item.rs | 2 +- .../src/protocol/v2/tests.rs | 1 + codex-rs/core/src/event_mapping.rs | 32 +++- codex-rs/core/src/event_mapping_tests.rs | 12 +- .../src/tools/handlers/extension_tools.rs | 2 + codex-rs/core/src/web_search.rs | 2 +- codex-rs/core/tests/common/responses.rs | 21 +++ codex-rs/core/tests/suite/client.rs | 1 + codex-rs/core/tests/suite/items.rs | 59 ++++++- .../tests/event_processor_with_json_output.rs | 2 + codex-rs/ext/web-search/src/tool.rs | 3 + codex-rs/protocol/src/models.rs | 155 ++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 1 + 15 files changed, 283 insertions(+), 15 deletions(-) diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 6bef88f64205..a29d89dad94d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2534,7 +2534,7 @@ fn web_search_action_kind(action: &WebSearchAction) -> WebSearchActionKind { fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Option { match action { - Some(WebSearchAction::Search { query, queries }) => queries + Some(WebSearchAction::Search { query, queries, .. }) => queries .as_ref() .map(|queries| usize_to_u64(queries.len())) .or_else(|| query.as_ref().map(|_| 1)), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index b9205a1460ee..087802e1041c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2449,6 +2449,7 @@ mod tests { action: CoreWebSearchAction::Search { query: Some("codex".into()), queries: None, + sources: None, }, results: Some(vec![CoreWebSearchResult { url: "https://example.com/codex".into(), @@ -3933,6 +3934,7 @@ mod tests { action: CoreWebSearchAction::Search { query: Some("codex".into()), queries: None, + sources: None, }, results: None, }), @@ -4070,6 +4072,7 @@ mod tests { action: CoreWebSearchAction::Search { query: Some("codex".into()), queries: None, + sources: None, }, results: None, })), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index d3db8afa6c5d..edf96e5c43e7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -826,7 +826,7 @@ pub enum WebSearchAction { impl From for WebSearchAction { fn from(value: codex_protocol::models::WebSearchAction) -> Self { match value { - codex_protocol::models::WebSearchAction::Search { query, queries } => { + codex_protocol::models::WebSearchAction::Search { query, queries, .. } => { WebSearchAction::Search { query, queries } } codex_protocol::models::WebSearchAction::OpenPage { url } => { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 0132137f276e..b0499ff578af 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2777,6 +2777,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { action: CoreWebSearchAction::Search { query: Some("docs".to_string()), queries: None, + sources: None, }, results: Some(vec![CoreWebSearchResult { url: "https://openai.com/docs".to_string(), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 6eb89b104e05..41a26e94e338 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -4,12 +4,14 @@ use codex_protocol::items::ReasoningItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; +use codex_protocol::items::WebSearchResult; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; +use codex_protocol::models::WebSearchSource; use codex_protocol::models::is_image_close_tag_text; use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; @@ -196,15 +198,37 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { })) } ResponseItem::WebSearchCall { id, action, .. } => { - let (action, query) = match action { - Some(action) => (action.clone(), web_search_action_detail(action)), - None => (WebSearchAction::Other, String::new()), + let (action, query, results) = match action { + Some(action) => { + let query = web_search_action_detail(action); + let mut action = action.clone(); + let results = match &mut action { + WebSearchAction::Search { sources, .. } => sources + .take() + .map(|sources| { + sources + .into_iter() + .map(|source| match source { + WebSearchSource::Url { url } => WebSearchResult { + url, + title: None, + snippet: None, + }, + }) + .collect::>() + }) + .filter(|results| !results.is_empty()), + _ => None, + }; + (action, query, results) + } + None => (WebSearchAction::Other, String::new(), None), }; Some(TurnItem::WebSearch(WebSearchItem { id: id.clone().unwrap_or_default(), query, action, - results: None, + results, })) } ResponseItem::ImageGenerationCall { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 93195525d1fc..f93715aa0249 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -8,6 +8,7 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; +use codex_protocol::items::WebSearchResult; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -15,6 +16,7 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; +use codex_protocol::models::WebSearchSource; use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG; @@ -492,6 +494,9 @@ fn parses_web_search_call() { action: Some(WebSearchAction::Search { query: Some("weather".to_string()), queries: None, + sources: Some(vec![WebSearchSource::Url { + url: "https://example.com/weather".to_string(), + }]), }), internal_chat_message_metadata_passthrough: None, }; @@ -507,8 +512,13 @@ fn parses_web_search_call() { action: WebSearchAction::Search { query: Some("weather".to_string()), queries: None, + sources: None, }, - results: None, + results: Some(vec![WebSearchResult { + url: "https://example.com/weather".to_string(), + title: None, + snippet: None, + }]), } ), other => panic!("expected TurnItem::WebSearch, got {other:?}"), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 20373a19bee0..a4ba87e4cb74 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -272,6 +272,7 @@ mod tests { action: WebSearchAction::Search { query: Some("rust trait object".to_string()), queries: None, + sources: None, }, results: None, }); @@ -438,6 +439,7 @@ mod tests { action: WebSearchAction::Search { query: Some("rust trait object".to_string()), queries: None, + sources: None, }, results: None, }; diff --git a/codex-rs/core/src/web_search.rs b/codex-rs/core/src/web_search.rs index d3c895c5faa9..240a9117d587 100644 --- a/codex-rs/core/src/web_search.rs +++ b/codex-rs/core/src/web_search.rs @@ -17,7 +17,7 @@ fn search_action_detail(query: &Option, queries: &Option>) - pub fn web_search_action_detail(action: &WebSearchAction) -> String { match action { - WebSearchAction::Search { query, queries } => search_action_detail(query, queries), + WebSearchAction::Search { query, queries, .. } => search_action_detail(query, queries), WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(), WebSearchAction::FindInPage { url, pattern } => match (pattern, url) { (Some(pattern), Some(url)) => format!("'{pattern}' in {url}"), diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 1ab3a70cbb9e..4802a462eb98 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -823,6 +823,27 @@ pub fn ev_web_search_call_done(id: &str, status: &str, query: &str) -> Value { }) } +pub fn ev_web_search_call_done_with_sources( + id: &str, + status: &str, + query: &str, + sources: &[&str], +) -> Value { + let sources = sources + .iter() + .map(|url| serde_json::json!({"type": "url", "url": url})) + .collect::>(); + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "id": id, + "status": status, + "action": {"type": "search", "query": query, "sources": sources} + } + }) +} + pub fn ev_image_generation_call( id: &str, status: &str, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 4d298998afb0..b4bc85e08a72 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -3040,6 +3040,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { action: Some(WebSearchAction::Search { query: Some("weather".into()), queries: None, + sources: None, }), internal_chat_message_metadata_passthrough: None, }); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 196277394a50..35b2baa2ee73 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -7,6 +7,8 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::TurnItem; +use codex_protocol::items::WebSearchItem; +use codex_protocol::items::WebSearchResult; use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::AskForApproval; @@ -30,7 +32,7 @@ use core_test_support::responses::ev_reasoning_summary_text_delta; use core_test_support::responses::ev_reasoning_text_delta; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_web_search_call_added_partial; -use core_test_support::responses::ev_web_search_call_done; +use core_test_support::responses::ev_web_search_call_done_with_sources; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -285,7 +287,12 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { let TestCodex { codex, .. } = test_codex().build(&server).await?; let web_search_added = ev_web_search_call_added_partial("web-search-1", "in_progress"); - let web_search_done = ev_web_search_call_done("web-search-1", "completed", "weather seattle"); + let web_search_done = ev_web_search_call_done_with_sources( + "web-search-1", + "completed", + "weather seattle", + &["https://weather.example/seattle"], + ); let first_response = sse(vec![ ev_response_created("resp-1"), @@ -335,16 +342,54 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { assert_eq!(begin.call_id, "web-search-1"); assert_eq!(started.0.id, begin.call_id); assert!(started.1 > 0); - assert_eq!(completed.0.id, begin.call_id); assert!(completed.1 > 0); assert_eq!( - completed.0.action, - WebSearchAction::Search { - query: Some("weather seattle".to_string()), - queries: None, + completed.0, + WebSearchItem { + id: begin.call_id, + query: "weather seattle".to_string(), + action: WebSearchAction::Search { + query: Some("weather seattle".to_string()), + queries: None, + sources: None, + }, + results: Some(vec![WebSearchResult { + url: "https://weather.example/seattle".to_string(), + title: None, + snippet: None, + }]), } ); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + let second_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "summarize the search".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = second_mock.single_request(); + let web_search_items = request.inputs_of_type("web_search_call"); + assert_eq!(web_search_items.len(), 1); + assert!(web_search_items[0].pointer("/action/sources").is_none()); + Ok(()) } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 6fe9fd8d3b71..3a9550d7123d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -395,6 +395,7 @@ fn web_search_completion_preserves_query_and_action() { action: WebSearchAction::Search { query: Some("rust async await".to_string()), queries: None, + sources: None, }, results: Some(vec![WebSearchResult { url: "https://example.com/rust-async".to_string(), @@ -472,6 +473,7 @@ fn web_search_start_and_completion_reuse_item_id() { action: WebSearchAction::Search { query: Some("rust async await".to_string()), queries: None, + sources: None, }, results: None, }), diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index e81270d921d7..0ce54946873d 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -168,10 +168,12 @@ fn query_action(queries: &[SearchQuery]) -> Option { [query] => Some(WebSearchAction::Search { query: Some(query.q.clone()), queries: None, + sources: None, }), queries => Some(WebSearchAction::Search { query: None, queries: Some(queries.iter().map(|query| query.q.clone()).collect()), + sources: None, }), } } @@ -205,6 +207,7 @@ mod tests { WebSearchAction::Search { query: None, queries: Some(vec!["waterfalls".to_string(), "mountains".to_string()]), + sources: None, }, ), ( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 46aecb61b97f..7ecd8b571bea 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1668,6 +1668,53 @@ pub struct LocalShellExecAction { pub user: Option, } +/// Transient metadata returned by hosted search for client rendering. +/// +/// The containing field is deserialize-only so these values are never sent back to the model. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WebSearchSource { + Url { url: String }, +} + +const MAX_WEB_SEARCH_SOURCES: usize = 20; +const MAX_WEB_SEARCH_SOURCE_URL_BYTES: usize = 512; +const MAX_WEB_SEARCH_SOURCE_TOTAL_URL_BYTES: usize = 2_048; + +fn bounded_web_search_sources( + sources: impl IntoIterator, +) -> Vec { + let mut bounded_sources = Vec::new(); + let mut total_url_bytes = 0usize; + + for source in sources { + if bounded_sources.len() == MAX_WEB_SEARCH_SOURCES { + break; + } + let url_bytes = match &source { + WebSearchSource::Url { url } => url.len(), + }; + if url_bytes > MAX_WEB_SEARCH_SOURCE_URL_BYTES + || total_url_bytes.saturating_add(url_bytes) > MAX_WEB_SEARCH_SOURCE_TOTAL_URL_BYTES + { + continue; + } + total_url_bytes += url_bytes; + bounded_sources.push(source); + } + + bounded_sources +} + +fn deserialize_web_search_sources<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.map(bounded_web_search_sources)) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[schemars(rename = "ResponsesApiWebSearchAction")] @@ -1679,6 +1726,14 @@ pub enum WebSearchAction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] queries: Option>, + #[serde( + default, + deserialize_with = "deserialize_web_search_sources", + skip_serializing + )] + #[schemars(skip)] + #[ts(skip)] + sources: Option>, }, OpenPage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3184,6 +3239,7 @@ mod tests { Some(WebSearchAction::Search { query: Some("weather seattle".into()), queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]), + sources: None, }), Some("completed".into()), ), @@ -3249,6 +3305,105 @@ mod tests { Ok(()) } + #[test] + fn web_search_sources_are_deserialized_but_not_serialized() -> Result<()> { + let item: ResponseItem = serde_json::from_value(serde_json::json!({ + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "weather seattle", + "sources": [{ + "type": "url", + "url": "https://example.com/weather", + }], + }, + }))?; + + assert!(matches!( + &item, + ResponseItem::WebSearchCall { + action: Some(WebSearchAction::Search { + sources: Some(sources), + .. + }), + .. + } if sources == &[WebSearchSource::Url { + url: "https://example.com/weather".to_string(), + }] + )); + assert!( + serde_json::to_value(item)? + .pointer("/action/sources") + .is_none() + ); + + Ok(()) + } + + #[test] + fn web_search_sources_are_bounded_on_deserialization() -> Result<()> { + let valid_urls = (0..21) + .map(|index| format!("https://example.com/article/{index}")) + .collect::>(); + let sources = std::iter::once(format!("https://example.com/{}", "x".repeat(600))) + .chain(valid_urls.iter().cloned()) + .map(|url| serde_json::json!({"type": "url", "url": url})) + .collect::>(); + + let action: WebSearchAction = serde_json::from_value(serde_json::json!({ + "type": "search", + "sources": sources, + }))?; + + assert_eq!( + action, + WebSearchAction::Search { + query: None, + queries: None, + sources: Some( + valid_urls + .into_iter() + .take(20) + .map(|url| WebSearchSource::Url { url }) + .collect() + ), + } + ); + Ok(()) + } + + #[test] + fn web_search_source_total_url_bytes_are_bounded_on_deserialization() -> Result<()> { + let urls = (0..5) + .map(|index| format!("https://example.com/{index}/{}", "x".repeat(480))) + .collect::>(); + let sources = urls + .iter() + .map(|url| serde_json::json!({"type": "url", "url": url})) + .collect::>(); + + let action: WebSearchAction = serde_json::from_value(serde_json::json!({ + "type": "search", + "sources": sources, + }))?; + + assert_eq!( + action, + WebSearchAction::Search { + query: None, + queries: None, + sources: Some( + urls.into_iter() + .take(4) + .map(|url| WebSearchSource::Url { url }) + .collect() + ), + } + ); + Ok(()) + } + #[test] fn serializes_image_user_input_without_tags() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 999fa6b1e656..5d092e5de93b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -5046,6 +5046,7 @@ mod tests { action: WebSearchAction::Search { query: Some("find docs".into()), queries: None, + sources: None, }, results: None, }),