Show activity for standalone web search calls (#24693)

## Why

Standalone `web.run` calls run in the extension, so they need normal
web-search progress activity while a request is in flight and durable
completed activity after a thread is reloaded.

Follow-up to #23823; uses the extension turn-item emission path added in
#24813.

## What changed

- Emit standalone `web.run` start/completion items through the host
turn-item emitter, preserving standard client delivery and rollout
persistence.
- Include useful completion detail for queries, image queries, and
literal-URL `open`/`find` commands.
- Render completed searches as `Searched the web` or `Searched the web
for <detail>`, with snapshot coverage for the detail-free case.
- Extend the app-server round-trip test to verify completed search
activity is reconstructed by `thread/read` after a fresh-process reload.

## Testing

- `just test -p codex-web-search-extension`
- `just test -p codex-app-server -E
"test(standalone_web_search_round_trips_encrypted_output)"`
This commit is contained in:
sayan-oai
2026-05-29 09:12:58 -07:00
committed by GitHub
Unverified
parent 5577a9e148
commit 96f1347fa3
10 changed files with 246 additions and 14 deletions
+1
View File
@@ -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 }
+6 -1
View File
@@ -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() {
@@ -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)]
);
}
}
+122 -2
View File
@@ -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;
@@ -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 {
@@ -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()
@@ -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()))?;
call.turn_item_emitter
.emit_completed(web_search_item(&call.call_id, command_action))
.await;
Ok(Box::new(EncryptedSearchOutput::new(
response.encrypted_output,
@@ -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);
}
}
}