diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e873b006f0a7..986c986ab33e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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(), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e025a61a3344..dbd61d387506 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 15d8812d9489..3db8b10e9f13 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7906d696a105..d3a15f2a4927 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index dcf6312c7d19..bc2b354bd460 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 1933225adef0..a226ce129b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 6851ddb487d7..db96b3c149ff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 59a14cc070b1..ad36cab22615 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index e5cbc2e60606..9b1ecd600751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index cdd1d47a6997..f9cd2152bc6b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 613dd9648e79..20587d98c6e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 80fbb183f8a3..e44a3c638bc5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 793f11c22617..f69cab4f2485 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 3ef1577038df..a697e9d91a8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index a56d59c01df5..32220637e984 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 6cc9b44cba3b..16da7ae525f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 6ffb343595bf..73cf9e470d96 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 805a5099a299..a11434c51297 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 1f3f7c7036de..78123128e209 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts index bc1e6d54a060..9ce72a2f742f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 2c6720f98a0a..de611a131a1d 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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 { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 7a5e4dc1f45c..9fb41c1be6bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -902,6 +902,7 @@ impl From 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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index d35d39fe4626..c529e2aa77da 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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!( diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7612436f5bea..471a9761926d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index d6c71d070383..d34f14e57a7f 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -162,6 +162,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { let search_body = search_request .body_json::() .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) diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 45a5790d80fa..131a335e2692 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -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()), + } + ); + } } diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index 7219684af1ae..237e7a7ebff4 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -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, 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>, } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 44c804f94836..1339eea03b9e 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -206,6 +206,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { id: id.as_deref().unwrap_or_default().to_string(), query, action, + results: None, })) } ResponseItem::ImageGenerationCall { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index ab4077f149f4..36f840c0c019 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -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:?}"), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index fb1e10f523f6..0d2e889db9d9 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -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, } ); } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 3abdbd716baa..fa417e8fda93 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -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(), diff --git a/codex-rs/ext/items/Cargo.toml b/codex-rs/ext/items/Cargo.toml index fdf848eddf23..4d58b29bc700 100644 --- a/codex-rs/ext/items/Cargo.toml +++ b/codex-rs/ext/items/Cargo.toml @@ -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 } diff --git a/codex-rs/ext/items/src/tests.rs b/codex-rs/ext/items/src/tests.rs index af4dd0c198e4..18c5085a130c 100644 --- a/codex-rs/ext/items/src/tests.rs +++ b/codex-rs/ext/items/src/tests.rs @@ -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::(value).expect("deserialize extension item"), item ); + assert_eq!( + serde_json::from_value::(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] diff --git a/codex-rs/ext/items/src/web_search.rs b/codex-rs/ext/items/src/web_search.rs index e878f4e7e293..2f8d9050400d 100644 --- a/codex-rs/ext/items/src/web_search.rs +++ b/codex-rs/ext/items/src/web_search.rs @@ -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, + /// 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>, } // App-server-facing description of the action performed by standalone web search. diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index eb3a92a70187..93d18dc58012 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -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))) } } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index f3b261f06970..14792421a4c2 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -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>, } #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index 63d2c41544ac..fa6677bfc953 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -411,6 +411,7 @@ impl WebSearchItem { call_id: self.id.clone(), query: self.query.clone(), action: self.action.clone(), + results: self.results.clone(), }) } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 59dec101e44e..fe66cfe67208 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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>, } #[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, };