Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions codex-rs/app-server/tests/suite/v2/web_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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,
}],
})
);
Expand All @@ -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);

Expand Down Expand Up @@ -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)
Expand Down
118 changes: 114 additions & 4 deletions codex-rs/ext/web-search/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -176,26 +186,77 @@ fn query_action(queries: &[SearchQuery]) -> Option<WebSearchAction> {
}
}

fn search_results(output: &str) -> Vec<WebSearchResult> {
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())
Comment on lines +201 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate result headers before splitting snippets

When a standalone search snippet contains a line ending in ordinary parentheses, such as ... (Reuters), this delimiter treats it as the next result header before any URL validation. The parser then rejects that bogus header later and continues scanning for the next header, which drops that snippet line and any following non-header lines from the rendered preview. Search snippets are arbitrary source text, so only validated http(s) result headers should terminate snippet collection.

Useful? React with 👍 / 👎.

{
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<String> {
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<Vec<WebSearchResult>>,
) -> 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() {
Expand Down Expand Up @@ -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::<Vec<_>>()
.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());
}
}
Loading