Preserve web search actions and results in exec JSON output (#46319)

## Why

Web search events in `codex exec --json` dropped structured results and relied on a serialization round trip to convert action types.

## What changed

Map web search actions explicitly, preserving `open_page` URLs and `find_in_page` URLs and patterns. Forward structured results through an optional `results` field, omitting it when unavailable while preserving empty arrays and error payloads.

## Testing

Add regression coverage for page actions with absent, empty, successful, and error results in serialized `item.completed` events.

GitOrigin-RevId: 9a7f4c9c2eb163ba62f5e1642437a9debffc3816
This commit is contained in:
Eric Traut
2026-09-17 23:17:33 +00:00
committed by copyberry
parent a129392ebb
commit f8c6026c38
3 changed files with 75 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction;
use codex_core::config::Config;
use codex_protocol::models::WebSearchAction;
use codex_protocol::protocol::SessionConfiguredEvent;
@@ -305,12 +306,18 @@ impl EventProcessorWithJsonOutput {
id: item.id,
query: item.query,
action: match item.action {
Some(action) => serde_json::from_value(
serde_json::to_value(action).unwrap_or_else(|_| json!("other")),
)
.unwrap_or(WebSearchAction::Other),
None => WebSearchAction::Other,
Some(ApiWebSearchAction::Search { query, queries }) => {
WebSearchAction::Search { query, queries }
}
Some(ApiWebSearchAction::OpenPage { url }) => {
WebSearchAction::OpenPage { url }
}
Some(ApiWebSearchAction::FindInPage { url, pattern }) => {
WebSearchAction::FindInPage { url, pattern }
}
Some(ApiWebSearchAction::Other) | None => WebSearchAction::Other,
},
results: item.results,
}),
}),
_ => None,

View File

@@ -299,6 +299,10 @@ pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: WebSearchAction,
/// Structured results returned by web search, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub results: Option<Vec<JsonValue>>,
}
/// An error notification.

View File

@@ -399,6 +399,7 @@ fn web_search_completion_preserves_query_and_action() {
query: Some("rust async await".to_string()),
queries: None,
},
results: None,
}),
},
})],
@@ -407,6 +408,62 @@ fn web_search_completion_preserves_query_and_action() {
);
}
#[test]
fn web_search_page_actions_and_results_survive_json_output() {
let url = "https://example.com/docs";
for (action, expected_action) in [
(
ApiWebSearchAction::OpenPage {
url: Some(url.to_string()),
},
json!({"type": "open_page", "url": url}),
),
(
ApiWebSearchAction::FindInPage {
url: Some(url.to_string()),
pattern: Some("configuration".to_string()),
},
json!({"type": "find_in_page", "url": url, "pattern": "configuration"}),
),
] {
for results in [
None,
Some(vec![]),
Some(vec![json!({"url": url, "content": "configuration"})]),
Some(vec![json!({"url": url, "error": {"status": 404}})]),
] {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::WebSearch(ApiWebSearchItem {
id: "search-1".to_string(),
query: url.to_string(),
action: Some(action.clone()),
results: results.clone(),
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
));
let mut expected_item = json!({
"id": "search-1",
"type": "web_search",
"query": url,
"action": expected_action,
});
if let Some(results) = results {
expected_item["results"] = json!(results);
}
assert_eq!(
serde_json::to_value(collected.events).expect("serialize web search events"),
json!([{"type": "item.completed", "item": expected_item}]),
);
}
}
}
#[test]
fn web_search_start_and_completion_reuse_item_id() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
@@ -451,6 +508,7 @@ fn web_search_start_and_completion_reuse_item_id() {
id: "search-1".to_string(),
query: String::new(),
action: WebSearchAction::Other,
results: None,
}),
},
})],
@@ -470,6 +528,7 @@ fn web_search_start_and_completion_reuse_item_id() {
query: Some("rust async await".to_string()),
queries: None,
},
results: None,
}),
},
})],