Skip to content
Merged
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
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

89 changes: 88 additions & 1 deletion codex-rs/app-server/tests/suite/v2/web_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
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_config::types::AuthCredentialsStoreMode;
use core_test_support::responses;
use pretty_assertions::assert_eq;
Expand Down Expand Up @@ -84,10 +90,11 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let thread_id = thread.id.clone();

let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
thread_id: thread_id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Search the web".to_string(),
Expand All @@ -103,6 +110,13 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;

let started = timeout(DEFAULT_READ_TIMEOUT, wait_for_web_search_started(&mut mcp)).await??;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
wait_for_web_search_completed(&mut mcp),
)
.await??;

timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
Expand Down Expand Up @@ -159,10 +173,83 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
}],
})
);
assert_eq!(
started.item,
ThreadItem::WebSearch {
id: call_id.to_string(),
query: String::new(),
action: Some(WebSearchAction::Other),
}
);
let expected_completed_item = ThreadItem::WebSearch {
id: call_id.to_string(),
query: "standalone web search".to_string(),
action: Some(WebSearchAction::Search {
query: Some("standalone web search".to_string()),
queries: None,
}),
};
assert_eq!(completed.item, expected_completed_item);

drop(mcp);
let mut reloaded_mcp =
McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.initialize()).await??;
let read_req = reloaded_mcp
.send_thread_read_request(ThreadReadParams {
thread_id,
include_turns: true,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
reloaded_mcp.read_stream_until_response_message(RequestId::Integer(read_req)),
)
.await??;
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
let persisted_web_searches: Vec<&ThreadItem> = thread
.turns
.iter()
.flat_map(|turn| &turn.items)
.filter(|item| matches!(item, ThreadItem::WebSearch { .. }))
.collect();
assert_eq!(persisted_web_searches, vec![&expected_completed_item]);

Ok(())
}

async fn wait_for_web_search_started(mcp: &mut McpProcess) -> Result<ItemStartedNotification> {
loop {
let notification = mcp
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification = serde_json::from_value(
notification
.params
.context("item/started notification should include params")?,
)?;
if matches!(&started.item, ThreadItem::WebSearch { .. }) {
return Ok(started);
}
}
}

async fn wait_for_web_search_completed(mcp: &mut McpProcess) -> Result<ItemCompletedNotification> {
loop {
let notification = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
notification
.params
.context("item/completed notification should include params")?,
)?;
if matches!(&completed.item, ThreadItem::WebSearch { .. }) {
return Ok(completed);
}
}
}

async fn mount_search_response(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/api/codex/alpha/search"))
Expand Down
1 change: 1 addition & 0 deletions codex-rs/ext/web-search/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ codex-tools = { workspace = true }
http = { workspace = true }
schemars = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }

[dev-dependencies]
pretty_assertions = { workspace = true }
7 changes: 6 additions & 1 deletion codex-rs/ext/web-search/src/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ mod tests {
use super::Config;
use super::WebSearchExtensionConfig;
use super::install;
use crate::tool::RUN_TOOL_NAME;
use crate::tool::WEB_NAMESPACE;

#[test]
fn installed_extension_contributes_web_run_when_enabled() {
Expand All @@ -170,6 +172,9 @@ mod tests {
.map(|tool| tool.tool_name())
.collect::<Vec<_>>();

assert_eq!(tool_names, vec![ToolName::namespaced("web", "run")]);
assert_eq!(
tool_names,
vec![ToolName::namespaced(WEB_NAMESPACE, RUN_TOOL_NAME)]
);
}
}
124 changes: 122 additions & 2 deletions codex-rs/ext/web-search/src/tool.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use codex_api::ReqwestTransport;
use codex_api::SearchClient;
use codex_api::SearchCommands;
use codex_api::SearchQuery;
use codex_api::SearchRequest;
use codex_api::SearchSettings;
use codex_core::web_search_action_detail;
use codex_extension_api::ExtensionTurnItem;
use codex_extension_api::FunctionCallError;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall;
Expand All @@ -13,18 +16,21 @@ use codex_extension_api::ToolSpec;
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::models::WebSearchAction;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolExposure;
use codex_tools::default_namespace_description;
use http::HeaderMap;
use url::Url;

use crate::history::recent_input;
use crate::output::EncryptedSearchOutput;
use crate::schema::commands_schema;

const WEB_NAMESPACE: &str = "web";
const RUN_TOOL_NAME: &str = "run";
pub(crate) const WEB_NAMESPACE: &str = "web";
pub(crate) const RUN_TOOL_NAME: &str = "run";
const WEB_RUN_DESCRIPTION: &str = include_str!("../web_run_description.md");

pub(crate) struct WebSearchTool {
Expand Down Expand Up @@ -66,6 +72,7 @@ impl ToolExecutor<ToolCall> for WebSearchTool {

async fn handle(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let commands = parse_commands(&call)?;
let command_action = command_action(&commands);
let provider = self
.provider
.api_provider()
Expand All @@ -92,10 +99,16 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX),
),
};
call.turn_item_emitter
.emit_started(web_search_item(&call.call_id, WebSearchAction::Other))
.await;
let response = client
.search(&request, HeaderMap::new())
.await
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
Comment thread
sayan-oai marked this conversation as resolved.
call.turn_item_emitter
.emit_completed(web_search_item(&call.call_id, command_action))
.await;

Ok(Box::new(EncryptedSearchOutput::new(
response.encrypted_output,
Expand All @@ -112,3 +125,110 @@ fn parse_commands(call: &ToolCall) -> Result<SearchCommands, FunctionCallError>
serde_json::from_str(arguments)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
}

fn command_action(commands: &SearchCommands) -> WebSearchAction {
commands
.search_query
.as_deref()
.and_then(query_action)
.or_else(|| commands.image_query.as_deref().and_then(query_action))
.or_else(|| {
commands
.open
.as_deref()
.and_then(|operations| operations.first())
.and_then(|operation| {
literal_url(&operation.ref_id)
.map(|url| WebSearchAction::OpenPage { url: Some(url) })
})
})
.or_else(|| {
commands
.find
.as_deref()
.and_then(|operations| operations.first())
.map(|operation| WebSearchAction::FindInPage {
url: literal_url(&operation.ref_id),
pattern: Some(operation.pattern.clone()),
})
})
.unwrap_or(WebSearchAction::Other)
}

fn query_action(queries: &[SearchQuery]) -> Option<WebSearchAction> {
match queries {
[] => None,
[query] => Some(WebSearchAction::Search {
query: Some(query.q.clone()),
queries: None,
}),
queries => Some(WebSearchAction::Search {
query: None,
queries: Some(queries.iter().map(|query| query.q.clone()).collect()),
}),
}
}

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 {
ExtensionTurnItem::WebSearch(WebSearchItem {
id: call_id.to_string(),
query: web_search_action_detail(&action),
action,
})
}

#[cfg(test)]
mod tests {
use codex_api::SearchCommands;
use codex_protocol::models::WebSearchAction;
use pretty_assertions::assert_eq;

use super::command_action;

#[test]
fn command_action_reports_queries_and_navigation_detail() {
let cases = [
(
r#"{"image_query":[{"q":"waterfalls"},{"q":"mountains"}]}"#,
WebSearchAction::Search {
query: None,
queries: Some(vec!["waterfalls".to_string(), "mountains".to_string()]),
},
),
(
r#"{"open":[{"ref_id":"https://example.com/docs"}]}"#,
WebSearchAction::OpenPage {
url: Some("https://example.com/docs".to_string()),
},
),
(
r#"{"find":[{"ref_id":"https://example.com/docs","pattern":"install"}]}"#,
WebSearchAction::FindInPage {
url: Some("https://example.com/docs".to_string()),
pattern: Some("install".to_string()),
},
),
(
r#"{"find":[{"ref_id":"turn0search0","pattern":"install"}]}"#,
WebSearchAction::FindInPage {
url: None,
pattern: Some("install".to_string()),
},
),
(
r#"{"open":[{"ref_id":"turn0search0"}]}"#,
WebSearchAction::Other,
),
];

for (arguments, expected) in cases {
let commands: SearchCommands =
serde_json::from_str(arguments).expect("valid search command arguments");
assert_eq!(command_action(&commands), expected);
}
}
}
8 changes: 5 additions & 3 deletions codex-rs/tui/src/history_cell/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use super::*;

fn web_search_header(completed: bool) -> &'static str {
if completed {
"Searched"
"Searched the web"
} else {
"Searching the web"
}
Expand Down Expand Up @@ -104,7 +104,8 @@ impl HistoryCell for WebSearchCell {
let text: Text<'static> = if detail.is_empty() {
Line::from(vec![header.bold()]).into()
} else {
Line::from(vec![header.bold(), " ".into(), detail.into()]).into()
let separator = if self.completed { " for " } else { " " };
Line::from(vec![header.bold(), separator.into(), detail.into()]).into()
};
PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width)
}
Expand All @@ -115,7 +116,8 @@ impl HistoryCell for WebSearchCell {
if detail.is_empty() {
vec![Line::from(header)]
} else {
vec![Line::from(format!("{header} {detail}"))]
let separator = if self.completed { " for " } else { " " };
vec![Line::from(format!("{header}{separator}{detail}"))]
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
source: tui/src/history_cell.rs
expression: rendered
---
• Searched example search query with several generic words to
exercise wrapping
• Searched the web for example search query with several generic
words to exercise wrapping
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
source: tui/src/history_cell.rs
expression: rendered
---
• Searched example search query with several generic words to
exercise wrapping
• Searched the web for example search query with several generic
words to exercise wrapping
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Searched the web
Loading
Loading