From 93b16cecf31c20c55c5da6ca380578b929324025 Mon Sep 17 00:00:00 2001 From: Alexi Christakis <167903946+alexi-openai@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:28:50 -0700 Subject: [PATCH] Return standalone web search previews to clients --- .../app-server/tests/suite/v2/web_search.rs | 27 +++- codex-rs/ext/web-search/src/tool.rs | 118 +++++++++++++++++- 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index f932c4c67dc3..29599ce66c61 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::WebSearchAction; +use codex_app_server_protocol::WebSearchResult; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; @@ -40,6 +41,15 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); #[cfg(not(any(target_os = "macos", windows)))] const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const SEARCH_OUTPUT: &str = r#"OpenAI docs (https://openai.com/docs) +Documentation + +OpenAI docs (https://openai.com/docs) +Duplicate result + +Example article (https://example.com/article) +Article excerpt"#; + #[tokio::test] async fn standalone_web_search_round_trips_output() -> Result<()> { let call_id = "web-run-1"; @@ -177,7 +187,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { "call_id": call_id, "output": [{ "type": "input_text", - "text": "Search result", + "text": SEARCH_OUTPUT, }], }) ); @@ -197,7 +207,18 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { query: Some("standalone web search".to_string()), queries: None, }), - results: None, + results: Some(vec![ + WebSearchResult { + url: "https://openai.com/docs".to_string(), + title: Some("OpenAI docs".to_string()), + snippet: Some("Documentation".to_string()), + }, + WebSearchResult { + url: "https://example.com/article".to_string(), + title: Some("Example article".to_string()), + snippet: Some("Article excerpt".to_string()), + }, + ]), }; assert_eq!(completed.item, expected_completed_item); @@ -271,7 +292,7 @@ async fn mount_search_response(server: &MockServer) { .and(path("/api/codex/alpha/search")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "encrypted_output": "ciphertext", - "output": "Search result", + "output": SEARCH_OUTPUT, }))) .expect(1) .mount(server) diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index e81270d921d7..6409cee1b144 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -17,12 +17,15 @@ use codex_extension_api::parse_tool_input_schema_without_compaction; use codex_login::default_client::build_reqwest_client; use codex_model_provider::SharedModelProvider; use codex_protocol::items::WebSearchItem; +use codex_protocol::items::WebSearchResult; +use codex_protocol::items::bounded_web_search_results; use codex_protocol::models::WebSearchAction; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; use http::HeaderMap; +use std::collections::HashSet; use url::Url; use crate::history::recent_input; @@ -109,14 +112,21 @@ impl WebSearchTool { ), }; call.turn_item_emitter - .emit_started(web_search_item(&call.call_id, WebSearchAction::Other)) + .emit_started(web_search_item( + &call.call_id, + WebSearchAction::Other, + /*results*/ None, + )) .await; let response = client .search(&request, HeaderMap::new()) .await .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + let results = matches!(&command_action, WebSearchAction::Search { .. }) + .then(|| search_results(&response.output)) + .filter(|results| !results.is_empty()); call.turn_item_emitter - .emit_completed(web_search_item(&call.call_id, command_action)) + .emit_completed(web_search_item(&call.call_id, command_action, results)) .await; Ok(Box::new(SearchOutput::new(response.output))) @@ -176,26 +186,77 @@ fn query_action(queries: &[SearchQuery]) -> Option { } } +fn search_results(output: &str) -> Vec { + let mut urls = HashSet::new(); + let mut lines = output.lines().peekable(); + bounded_web_search_results(std::iter::from_fn(|| { + loop { + let (title, url) = loop { + let line = lines.next()?; + if let Some(header) = search_result_header(line) { + break header; + } + }; + let mut snippet = String::new(); + while lines + .peek() + .is_some_and(|line| search_result_header(line).is_none()) + { + let line = lines.next()?.trim(); + if line.is_empty() { + continue; + } + if !snippet.is_empty() { + snippet.push('\n'); + } + snippet.push_str(line); + } + let Ok(parsed) = Url::parse(url) else { + continue; + }; + if !matches!(parsed.scheme(), "http" | "https") || !urls.insert(url) { + continue; + } + return Some(WebSearchResult { + url: url.to_string(), + title: Some(title.to_string()), + snippet: Some(snippet), + }); + } + })) +} + +fn search_result_header(line: &str) -> Option<(&str, &str)> { + let (title, url) = line.rsplit_once(" (")?; + Some((title, url.strip_suffix(')')?)) +} + fn literal_url(ref_id: &str) -> Option { Url::parse(ref_id).is_ok().then(|| ref_id.to_string()) } -fn web_search_item(call_id: &str, action: WebSearchAction) -> ExtensionTurnItem { +fn web_search_item( + call_id: &str, + action: WebSearchAction, + results: Option>, +) -> ExtensionTurnItem { ExtensionTurnItem::WebSearch(WebSearchItem { id: call_id.to_string(), query: web_search_action_detail(&action), action, - results: None, + results, }) } #[cfg(test)] mod tests { use codex_api::SearchCommands; + use codex_protocol::items::WebSearchResult; use codex_protocol::models::WebSearchAction; use pretty_assertions::assert_eq; use super::command_action; + use super::search_results; #[test] fn command_action_reports_queries_and_navigation_detail() { @@ -239,4 +300,53 @@ mod tests { assert_eq!(command_action(&commands), expected); } } + + #[test] + fn search_results_extract_unique_http_urls_titles_and_snippets() { + assert_eq!( + search_results( + r#"OpenAI docs (https://openai.com/docs) +Official API documentation + +OpenAI docs duplicate (https://openai.com/docs) +Duplicate result + +FTP mirror (ftp://example.com/archive) +Archive + +Example article (https://example.com/article) +First excerpt line +Second excerpt line"#, + ), + vec![ + WebSearchResult { + url: "https://openai.com/docs".to_string(), + title: Some("OpenAI docs".to_string()), + snippet: Some("Official API documentation".to_string()), + }, + WebSearchResult { + url: "https://example.com/article".to_string(), + title: Some("Example article".to_string()), + snippet: Some("First excerpt line\nSecond excerpt line".to_string()), + }, + ] + ); + } + + #[test] + fn search_results_are_bounded() { + let output = (0..=20) + .map(|index| format!("Result {index} (https://example{index}.com/article)")) + .collect::>() + .join("\n"); + + assert_eq!(search_results(&output).len(), 20); + } + + #[test] + fn search_results_drop_oversized_urls() { + let output = format!("Long result (https://example.com/{})", "x".repeat(600)); + + assert_eq!(search_results(&output), Vec::new()); + } }