Expose structured standalone web search results (#32898)

## Why

Standalone web search can return structured result DTOs separately from its
model-facing text output. App-server clients need access to that data without
coupling Codex to every result type or field.

## What changed

- Preserve the optional `results` array from `/v1/alpha/search` as opaque JSON
  through web-search completion events, thread history, and app-server
  `webSearch` items.
- Keep responses from older endpoints compatible when `results` is absent, and
  allow clients to ignore result types and fields they do not understand.
- Regenerate the protocol schemas and TypeScript definitions for the optional
  field.

## Testing

- Cover responses with missing, empty, and forward-compatible result payloads.
- Verify standalone web-search results round-trip through app-server item
  notifications and thread-item conversion.

GitOrigin-RevId: 739ef5694ece5733f7b8749ec41d6bfa3f33c8c5
This commit is contained in:
briansong-oai
2026-07-13 23:37:35 +00:00
committed by copyberry
parent 090f4de37b
commit 2ca20d5224
38 changed files with 329 additions and 4 deletions
@@ -4421,6 +4421,7 @@ async fn turn_event_counts_completed_tool_items() {
id: "web-1".to_string(),
query: "codex".to_string(),
action: None,
results: None,
}),
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
@@ -4511,6 +4511,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -18551,6 +18551,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -16330,6 +16330,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1102,6 +1102,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1102,6 +1102,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1775,6 +1775,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1775,6 +1775,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1775,6 +1775,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"default": null,
"description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.",
"items": true,
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1,6 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "./serde_json/JsonValue";
import type { WebSearchAction } from "./v2/WebSearchAction";
export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, };
export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null,
/**
* Structured search results returned out-of-band by standalone web search.
*
* These stay as opaque JSON at the extension/app-server boundary so new
* result fields and result types can pass through without a Codex release.
*/
results: Array<JsonValue> | null, };
@@ -634,6 +634,7 @@ impl ThreadHistoryBuilder {
id: payload.call_id.clone(),
query: String::new(),
action: None,
results: None,
});
self.upsert_item_in_current_turn(item);
}
@@ -643,6 +644,7 @@ impl ThreadHistoryBuilder {
id: payload.call_id.clone(),
query: payload.query.clone(),
action: Some(web_search_action_from_core(payload.action.clone())),
results: payload.results.clone(),
});
self.upsert_item_in_current_turn(item);
}
@@ -2664,6 +2666,11 @@ mod tests {
query: Some("codex".into()),
queries: None,
},
results: Some(vec![serde_json::json!({
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/codex",
})]),
}),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "exec-1".into(),
@@ -2720,6 +2727,11 @@ mod tests {
query: Some("codex".into()),
queries: None,
}),
results: Some(vec![serde_json::json!({
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/codex",
})]),
})
);
assert_eq!(
@@ -4195,6 +4207,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
},
results: None,
}),
));
assert_eq!(
@@ -4209,6 +4222,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
}),
results: None,
}),
}],
changed_turns: Vec::new(),
@@ -4330,6 +4344,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
},
results: None,
})),
]);
assert_eq!(
@@ -4344,6 +4359,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
}),
results: None,
}),
}],
changed_turns: vec![ThreadHistoryTurnChange {
@@ -902,6 +902,7 @@ impl From<CoreTurnItem> for ThreadItem {
id: search.id,
query: search.query,
action: Some(web_search_action_from_core(search.action)),
results: search.results,
}),
CoreTurnItem::ImageView(image) => ThreadItem::ImageView {
id: image.id,
@@ -2777,6 +2777,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
query: Some("docs".to_string()),
queries: None,
},
results: Some(vec![serde_json::json!({
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/docs",
})]),
});
let expected_search_item = WebSearchItem {
@@ -2786,6 +2791,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
query: Some("docs".to_string()),
queries: None,
}),
results: Some(vec![serde_json::json!({
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/docs",
})]),
};
assert_eq!(
+1 -1
View File
@@ -1375,7 +1375,7 @@ Today both notifications carry an empty `items` array even when item events were
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, templateId, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, `templateId` identifies the app template, and `actionName` is the stable connector `Action.name`. `appName`, `templateId`, and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `webSearch` — `{id, query, action?, results?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. For standalone web search, `results` contains the out-of-band structured result DTOs returned by `/v1/alpha/search`; clients should ignore result types and fields they do not understand.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
- `sleep` — `{id, durationMs}` emitted while the agent waits for a duration or new input.
- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description.
@@ -162,6 +162,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
let search_body = search_request
.body_json::<Value>()
.context("search request body should be JSON")?;
assert!(
search_body.get("result_fields").is_none(),
"standalone search should use the endpoint's default result projection"
);
assert_eq!(search_body["model"], json!("mock-model"));
assert_eq!(
search_body["commands"],
@@ -228,6 +232,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
id: call_id.to_string(),
query: String::new(),
action: None,
results: None,
})
);
let expected_completed_item = ThreadItem::WebSearch(WebSearchItem {
@@ -237,6 +242,14 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
query: Some("standalone web search".to_string()),
queries: None,
}),
results: Some(vec![json!({
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/search-result",
"title": "Search Result",
"snippet": "A result snippet",
"future_field": {"preserved": true},
})]),
});
assert_eq!(completed.item, expected_completed_item);
@@ -310,6 +323,14 @@ async fn mount_search_response(server: &MockServer) {
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encrypted_output": "ciphertext",
"output": "Search result",
"results": [{
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/search-result",
"title": "Search Result",
"snippet": "A result snippet",
"future_field": {"preserved": true},
}],
})))
.expect(1)
.mount(server)
+48
View File
@@ -139,6 +139,12 @@ mod tests {
serde_json::to_vec(&json!({
"encrypted_output": "ciphertext",
"output": "search result",
"results": [{
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/result",
"future_field": {"preserved": true},
}],
}))
.expect("serialize response"),
);
@@ -209,6 +215,12 @@ mod tests {
SearchResponse {
encrypted_output: Some("ciphertext".to_string()),
output: "search result".to_string(),
results: Some(vec![json!({
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com/result",
"future_field": {"preserved": true},
})]),
}
);
@@ -267,4 +279,40 @@ mod tests {
})
);
}
#[test]
fn search_response_defaults_missing_results_for_older_endpoints() {
let response: SearchResponse = serde_json::from_value(json!({
"encrypted_output": null,
"output": "search result",
}))
.expect("response without results should deserialize");
assert_eq!(
response,
SearchResponse {
encrypted_output: None,
output: "search result".to_string(),
results: None,
}
);
}
#[test]
fn search_response_preserves_supported_empty_results() {
let response: SearchResponse = serde_json::from_value(json!({
"encrypted_output": null,
"output": "search result",
"results": [],
}))
.expect("response with empty results should deserialize");
assert_eq!(
response,
SearchResponse {
encrypted_output: None,
output: "search result".to_string(),
results: Some(Vec::new()),
}
);
}
}
+5
View File
@@ -3,6 +3,7 @@ use codex_protocol::models::ResponseItem;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SearchRequest {
@@ -297,4 +298,8 @@ pub enum AllowedCaller {
pub struct SearchResponse {
pub encrypted_output: Option<String>,
pub output: String,
/// Structured result DTOs are passed to clients out-of-band from `output`.
/// Keep them opaque here so newer result variants remain forward-compatible.
#[serde(default)]
pub results: Option<Vec<JsonValue>>,
}
+1
View File
@@ -206,6 +206,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
id: id.as_deref().unwrap_or_default().to_string(),
query,
action,
results: None,
}))
}
ResponseItem::ImageGenerationCall {
+4
View File
@@ -509,6 +509,7 @@ fn parses_web_search_call() {
query: Some("weather".to_string()),
queries: None,
},
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -537,6 +538,7 @@ fn parses_web_search_open_page_call() {
action: WebSearchAction::OpenPage {
url: Some("https://example.com".to_string()),
},
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -567,6 +569,7 @@ fn parses_web_search_find_in_page_call() {
url: Some("https://example.com".to_string()),
pattern: Some("needle".to_string()),
},
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -590,6 +593,7 @@ fn parses_partial_web_search_call_without_action_as_other() {
id: "ws_partial".to_string(),
query: String::new(),
action: WebSearchAction::Other,
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -272,6 +272,7 @@ mod tests {
id: call.call_id.clone(),
query: String::new(),
action: None,
results: None,
}),
legacy_events: Vec::new(),
})
@@ -421,6 +422,7 @@ mod tests {
id: "call-extension".to_string(),
query: String::new(),
action: None,
results: None,
}
);
}
@@ -370,6 +370,7 @@ fn web_search_completion_preserves_query_and_action() {
query: Some("rust async await".to_string()),
queries: None,
}),
results: None,
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -408,6 +409,7 @@ fn web_search_start_and_completion_reuse_item_id() {
id: "search-1".to_string(),
query: String::new(),
action: None,
results: None,
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -423,6 +425,7 @@ fn web_search_start_and_completion_reuse_item_id() {
query: Some("rust async await".to_string()),
queries: None,
}),
results: None,
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
+1 -1
View File
@@ -16,8 +16,8 @@ workspace = true
codex-utils-absolute-path = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
+16
View File
@@ -46,6 +46,7 @@ fn web_search_item_preserves_stable_wire_shape() {
query: Some("docs".to_string()),
queries: None,
}),
results: None,
});
let value = serde_json::to_value(&item).expect("serialize extension item");
@@ -60,12 +61,27 @@ fn web_search_item_preserves_stable_wire_shape() {
"query": "docs",
"queries": null,
},
"results": null,
})
);
assert_eq!(
serde_json::from_value::<ExtensionItem>(value).expect("deserialize extension item"),
item
);
assert_eq!(
serde_json::from_value::<ExtensionItem>(json!({
"kind": "web.search",
"id": "search-1",
"query": "docs",
"action": {
"type": "search",
"query": "docs",
"queries": null,
},
}))
.expect("deserialize legacy extension item without results"),
item
);
}
#[test]
+7
View File
@@ -1,6 +1,7 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;
// Standalone web-search item owned by the web extension. This is also the
@@ -13,6 +14,12 @@ pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: Option<WebSearchAction>,
/// Structured search results returned out-of-band by standalone web search.
///
/// These stay as opaque JSON at the extension/app-server boundary so new
/// result fields and result types can pass through without a Codex release.
#[serde(default)]
pub results: Option<Vec<JsonValue>>,
}
// App-server-facing description of the action performed by standalone web search.
+6 -1
View File
@@ -127,6 +127,7 @@ impl WebSearchTool {
id: call.call_id.clone(),
query: String::new(),
action: None,
results: None,
},
EventMsg::WebSearchBegin(WebSearchBeginEvent {
call_id: call.call_id.clone(),
@@ -137,6 +138,8 @@ impl WebSearchTool {
.search(&request, extra_headers)
.await
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
let output = response.output;
let results = response.results;
let legacy_action = match &command_action {
WebSearchAction::Search { query, queries } => CoreWebSearchAction::Search {
query: query.clone(),
@@ -156,16 +159,18 @@ impl WebSearchTool {
id: call.call_id.clone(),
query: query.clone(),
action: Some(command_action),
results: results.clone(),
},
EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id: call.call_id.clone(),
query,
action: legacy_action,
results,
}),
))
.await;
Ok(Box::new(SearchOutput::new(response.output)))
Ok(Box::new(SearchOutput::new(output)))
}
}
+9
View File
@@ -31,6 +31,7 @@ use quick_xml::se::to_string as to_xml_string;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
@@ -299,6 +300,14 @@ pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: WebSearchAction,
/// Structured search results returned out-of-band by standalone web search.
///
/// These stay as opaque JSON at the Codex transport boundary so new result
/// fields and result types can pass through without changing model-visible
/// context or requiring a Codex release.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub results: Option<Vec<JsonValue>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
+1
View File
@@ -411,6 +411,7 @@ impl WebSearchItem {
call_id: self.id.clone(),
query: self.query.clone(),
action: self.action.clone(),
results: self.results.clone(),
})
}
}
+5
View File
@@ -2462,6 +2462,10 @@ pub struct WebSearchEndEvent {
pub call_id: String,
pub query: String,
pub action: WebSearchAction,
/// Structured search results returned out-of-band by standalone web search.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub results: Option<Vec<Value>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -5102,6 +5106,7 @@ mod tests {
query: Some("find docs".into()),
queries: None,
},
results: None,
}),
started_at_ms: 0,
};