From a219b6fdb4e9f9655968adf20984916abc8b2290 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 8 Jul 2026 09:40:00 -0700 Subject: [PATCH] core: migrate standalone web search to extension-owned turn items (#31525) ## Description This PR migrates standalone web search onto the extension-owned turn-item path introduced in #31283. Standalone web search now emits `ExtensionItem::WebSearch` through generic `TurnItem::Extension`, while app-server still exposes the existing typed `ThreadItem::WebSearch` JSON shape. Hosted Responses API web search stays on core-owned `TurnItem::WebSearch`. ## What changed - Added `web_search::WebSearchItem` and `WebSearchAction` to `codex-extension-items` under the stable `web.search` kind. - Collapsed `ExtensionTurnItem` to generic `{ item, legacy_events }` now that no typed extension special cases remain. - Kept the existing `WebSearchBegin` / `WebSearchEnd` compatibility events and canonical-first ordering. - Updated app-server projection/history and generated TypeScript; the app-server JSON schema is unchanged. --- codex-rs/Cargo.lock | 1 + .../analytics/src/analytics_client_tests.rs | 5 +- codex-rs/analytics/src/reducer.rs | 16 +-- .../schema/typescript/WebSearchItem.ts | 6 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/ThreadItem.ts | 4 +- .../src/protocol/thread_history.rs | 25 ++-- .../src/protocol/v2/item.rs | 65 ++++------- .../src/protocol/v2/tests.rs | 28 +++-- .../app-server/tests/suite/v2/web_search.rs | 17 +-- .../src/tools/handlers/extension_tools.rs | 107 ++++++------------ .../src/event_processor_with_human_output.rs | 8 +- .../src/event_processor_with_jsonl_output.rs | 12 +- .../tests/event_processor_with_json_output.rs | 13 ++- codex-rs/ext/image-generation/src/tool.rs | 2 +- codex-rs/ext/items/src/lib.rs | 5 + codex-rs/ext/items/src/tests.rs | 33 ++++++ codex-rs/ext/items/src/web_search.rs | 39 +++++++ codex-rs/ext/web-search/Cargo.toml | 1 + codex-rs/ext/web-search/src/tool.rs | 59 ++++++++-- codex-rs/protocol/src/items.rs | 8 +- codex-rs/tools/src/tool_call.rs | 10 +- codex-rs/tui/src/app/agent_status_feed.rs | 4 +- codex-rs/tui/src/chatwidget/protocol.rs | 4 +- codex-rs/tui/src/chatwidget/replay.rs | 11 +- codex-rs/tui/src/thread_transcript.rs | 4 +- 26 files changed, 279 insertions(+), 209 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts create mode 100644 codex-rs/ext/items/src/web_search.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9e250998ff..b80e8bc9d0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4471,6 +4471,7 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", + "codex-extension-items", "codex-login", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0789c9de48..36916534c6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -140,6 +140,7 @@ use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WebSearchItem; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; @@ -4401,11 +4402,11 @@ async fn turn_event_counts_completed_tool_items() { agent_thread_id: "thread-child".to_string(), agent_path: "/root/child".to_string(), }, - ThreadItem::WebSearch { + ThreadItem::WebSearch(WebSearchItem { id: "web-1".to_string(), query: "codex".to_string(), action: None, - }, + }), ThreadItem::ImageGeneration(ImageGenerationItem { id: "image-1".to_string(), status: "completed".to_string(), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index fa742b74db..79649a553b 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -402,7 +402,7 @@ impl TurnToolCounts { ThreadItem::CollabAgentToolCall { .. } | ThreadItem::SubAgentActivity { .. } => { self.subagent_tool_call += 1; } - ThreadItem::WebSearch { .. } => self.web_search += 1, + ThreadItem::WebSearch(_) => self.web_search += 1, ThreadItem::ImageGeneration(_) => self.image_generation += 1, ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } @@ -1732,8 +1732,8 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { | ThreadItem::FileChange { id, .. } | ThreadItem::McpToolCall { id, .. } | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::WebSearch { id, .. } => Some(id), + | ThreadItem::CollabAgentToolCall { id, .. } => Some(id), + ThreadItem::WebSearch(item) => Some(&item.id), ThreadItem::ImageGeneration(item) => Some(&item.id), ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } @@ -2027,11 +2027,11 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, )) } - ThreadItem::WebSearch { id, query, action } => { + ThreadItem::WebSearch(item) => { let base = tool_item_base( thread_id, turn_id, - id.clone(), + item.id.clone(), "web_search".to_string(), ToolItemOutcome { terminal_status: ToolItemTerminalStatus::Completed, @@ -2051,9 +2051,9 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { event_type: "codex_web_search_event", event_params: CodexWebSearchEventParams { base, - web_search_action: action.as_ref().map(web_search_action_kind), - query_present: !query.trim().is_empty(), - query_count: web_search_query_count(query, action.as_ref()), + web_search_action: item.action.as_ref().map(web_search_action_kind), + query_present: !item.query.trim().is_empty(), + query_count: web_search_query_count(&item.query, item.action.as_ref()), }, })) } diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts new file mode 100644 index 0000000000..bc1e6d54a0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchItem.ts @@ -0,0 +1,6 @@ +// 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 { WebSearchAction } from "./v2/WebSearchAction"; + +export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 092aa3c5e4..6237fb4448 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -82,6 +82,7 @@ export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; export type { WebSearchAction } from "./WebSearchAction"; export type { WebSearchContextSize } from "./WebSearchContextSize"; +export type { WebSearchItem } from "./WebSearchItem"; export type { WebSearchLocation } from "./WebSearchLocation"; export type { WebSearchMode } from "./WebSearchMode"; export type { WebSearchToolConfig } from "./WebSearchToolConfig"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 4cb8330b79..a445385581 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -5,6 +5,7 @@ import type { ImageGenerationItem } from "../ImageGenerationItem"; import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { WebSearchItem } from "../WebSearchItem"; import type { JsonValue } from "../serde_json/JsonValue"; import type { CollabAgentState } from "./CollabAgentState"; import type { CollabAgentTool } from "./CollabAgentTool"; @@ -24,7 +25,6 @@ import type { MemoryCitation } from "./MemoryCitation"; import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { SubAgentActivityKind } from "./SubAgentActivityKind"; import type { UserInput } from "./UserInput"; -import type { WebSearchAction } from "./WebSearchAction"; export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** @@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; 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 ca3849bb38..08fb4ecd5b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -21,7 +21,10 @@ use crate::protocol::v2::TurnError; use crate::protocol::v2::TurnItemsView; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; +#[cfg(test)] use crate::protocol::v2::WebSearchAction; +use crate::protocol::v2::WebSearchItem; +use crate::protocol::v2::web_search_action_from_core; use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::MessagePhase; @@ -613,20 +616,20 @@ impl ThreadHistoryBuilder { } fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) { - let item = ThreadItem::WebSearch { + let item = ThreadItem::WebSearch(WebSearchItem { id: payload.call_id.clone(), query: String::new(), action: None, - }; + }); self.upsert_item_in_current_turn(item); } fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { - let item = ThreadItem::WebSearch { + let item = ThreadItem::WebSearch(WebSearchItem { id: payload.call_id.clone(), query: payload.query.clone(), - action: Some(WebSearchAction::from(payload.action.clone())), - }; + action: Some(web_search_action_from_core(payload.action.clone())), + }); self.upsert_item_in_current_turn(item); } @@ -2549,14 +2552,14 @@ mod tests { assert_eq!(turns[0].items.len(), 4); assert_eq!( turns[0].items[1], - ThreadItem::WebSearch { + ThreadItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { query: Some("codex".into()), queries: None, }), - } + }) ); assert_eq!( turns[0].items[2], @@ -3982,14 +3985,14 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { query: Some("codex".into()), queries: None, }), - }, + }), }], changed_turns: Vec::new(), removed_turn_ids: Vec::new(), @@ -4117,14 +4120,14 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { query: Some("codex".into()), queries: None, }), - }, + }), }], changed_turns: vec![ThreadHistoryTurnChange { turn_id: "rollout-0".into(), 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 2fea01da6a..e5e131deb3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -13,6 +13,8 @@ use crate::protocol::item_builders::convert_patch_changes; use codex_experimental_api_macros::ExperimentalApi; use codex_extension_items::ExtensionItem; pub use codex_extension_items::image_generation::ImageGenerationItem; +pub use codex_extension_items::web_search::WebSearchAction; +pub use codex_extension_items::web_search::WebSearchItem; use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; @@ -358,13 +360,7 @@ pub enum ThreadItem { agent_thread_id: String, agent_path: String, }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WebSearch { - id: String, - query: String, - action: Option, - }, + WebSearch(WebSearchItem), #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ImageView { @@ -432,12 +428,12 @@ impl ThreadItem { | ThreadItem::DynamicToolCall { id, .. } | ThreadItem::CollabAgentToolCall { id, .. } | ThreadItem::SubAgentActivity { id, .. } - | ThreadItem::WebSearch { id, .. } | ThreadItem::ImageView { id, .. } | ThreadItem::Sleep { id, .. } | ThreadItem::EnteredReviewMode { id, .. } | ThreadItem::ExitedReviewMode { id, .. } | ThreadItem::ContextCompaction { id, .. } => id, + ThreadItem::WebSearch(item) => &item.id, ThreadItem::ImageGeneration(item) => &item.id, } } @@ -786,40 +782,20 @@ impl TryFrom for CoreGuardianAssessmentAction { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WebSearchAction { - Search { - query: Option, - queries: Option>, - }, - OpenPage { - url: Option, - }, - FindInPage { - url: Option, - pattern: Option, - }, - #[serde(other)] - Other, -} - -impl From for WebSearchAction { - fn from(value: codex_protocol::models::WebSearchAction) -> Self { - match value { - codex_protocol::models::WebSearchAction::Search { query, queries } => { - WebSearchAction::Search { query, queries } - } - codex_protocol::models::WebSearchAction::OpenPage { url } => { - WebSearchAction::OpenPage { url } - } - codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { - WebSearchAction::FindInPage { url, pattern } - } - codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, +pub(crate) fn web_search_action_from_core( + value: codex_protocol::models::WebSearchAction, +) -> WebSearchAction { + match value { + codex_protocol::models::WebSearchAction::Search { query, queries } => { + WebSearchAction::Search { query, queries } } + codex_protocol::models::WebSearchAction::OpenPage { url } => { + WebSearchAction::OpenPage { url } + } + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { + WebSearchAction::FindInPage { url, pattern } + } + codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, } } @@ -921,11 +897,11 @@ impl From for ThreadItem { agent_thread_id: activity.agent_thread_id.to_string(), agent_path: String::from(activity.agent_path), }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch(WebSearchItem { id: search.id, query: search.query, - action: Some(WebSearchAction::from(search.action)), - }, + action: Some(web_search_action_from_core(search.action)), + }), CoreTurnItem::ImageView(image) => ThreadItem::ImageView { id: image.id, path: image.path.into(), @@ -936,6 +912,7 @@ impl From for ThreadItem { }, CoreTurnItem::Extension(extension) => match extension { ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item), + ExtensionItem::WebSearch(item) => ThreadItem::WebSearch(item), }, CoreTurnItem::ImageGeneration(image) => { ThreadItem::ImageGeneration(ImageGenerationItem { 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 377ecba2c2..d35d39fe46 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -19,7 +19,7 @@ use codex_protocol::items::ReasoningItem; use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; -use codex_protocol::items::WebSearchItem; +use codex_protocol::items::WebSearchItem as CoreWebSearchItem; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::McpServerInfo; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; @@ -2770,7 +2770,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { } ); - let search_item = TurnItem::WebSearch(WebSearchItem { + let search_item = TurnItem::WebSearch(CoreWebSearchItem { id: "search-1".to_string(), query: "docs".to_string(), action: CoreWebSearchAction::Search { @@ -2779,16 +2779,24 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, }); + let expected_search_item = WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + }; + assert_eq!( ThreadItem::from(search_item), - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "docs".to_string(), - action: Some(WebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }), - } + ThreadItem::WebSearch(expected_search_item.clone()) + ); + assert_eq!( + ThreadItem::from(TurnItem::Extension( + codex_extension_items::ExtensionItem::WebSearch(expected_search_item.clone()), + )), + ThreadItem::WebSearch(expected_search_item) ); let image_view_item = TurnItem::ImageView(ImageViewItem { 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 f7d05395b4..01477466e4 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -20,6 +20,7 @@ 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_app_server_protocol::WebSearchItem; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; @@ -183,20 +184,20 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { ); assert_eq!( started.item, - ThreadItem::WebSearch { + ThreadItem::WebSearch(WebSearchItem { id: call_id.to_string(), query: String::new(), - action: Some(WebSearchAction::Other), - } + action: None, + }) ); - let expected_completed_item = ThreadItem::WebSearch { + let expected_completed_item = ThreadItem::WebSearch(WebSearchItem { 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); @@ -223,7 +224,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { .turns .iter() .flat_map(|turn| &turn.items) - .filter(|item| matches!(item, ThreadItem::WebSearch { .. })) + .filter(|item| matches!(item, ThreadItem::WebSearch(_))) .collect(); assert_eq!(persisted_web_searches, vec![&expected_completed_item]); @@ -240,7 +241,7 @@ async fn wait_for_web_search_started(mcp: &mut TestAppServer) -> Result (TurnItem::WebSearch(item), Vec::new()), - ExtensionTurnItem::Extension { - item, - legacy_events, - } => (TurnItem::Extension(item), legacy_events), - }; + let ExtensionTurnItem { + item, + legacy_events, + } = item; + let item = TurnItem::Extension(item); session.emit_turn_item_started(turn.as_ref(), &item).await; emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) @@ -104,25 +100,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let (item, legacy_events) = match item { - ExtensionTurnItem::Extension { - item, - legacy_events, - } => (TurnItem::Extension(item), legacy_events), - ExtensionTurnItem::WebSearch(item) => { - let mut item = TurnItem::WebSearch(item); - finalize_turn_item( - session.as_ref(), - turn.as_ref(), - TurnItemContributorPolicy::Run(turn.extension_data.as_ref()), - &mut item, - turn.collaboration_mode.mode - == codex_protocol::config_types::ModeKind::Plan, - ) - .await; - (item, Vec::new()) - } - }; + let ExtensionTurnItem { + item, + legacy_events, + } = item; + let item = TurnItem::Extension(item); session.emit_turn_item_completed(turn.as_ref(), item).await; emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) @@ -181,11 +163,10 @@ mod tests { use codex_extension_items::ExtensionItem; use codex_extension_items::image_generation::ImageGenerationItem; + use codex_extension_items::web_search::WebSearchItem; use codex_protocol::items::TurnItem; - use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; - use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ImageGenerationBeginEvent; use codex_protocol::protocol::ImageGenerationEndEvent; @@ -274,16 +255,16 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { - let item = ExtensionTurnItem::WebSearch(WebSearchItem { - id: call.call_id.clone(), - query: "rust trait object".to_string(), - action: WebSearchAction::Search { - query: Some("rust trait object".to_string()), - queries: None, - }, - }); - call.turn_item_emitter.emit_started(item.clone()).await; - call.turn_item_emitter.emit_completed(item).await; + call.turn_item_emitter + .emit_started(ExtensionTurnItem { + item: ExtensionItem::WebSearch(WebSearchItem { + id: call.call_id.clone(), + query: String::new(), + action: None, + }), + legacy_events: Vec::new(), + }) + .await; *self.captured_call.lock().await = Some(call); Ok( Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) @@ -420,39 +401,17 @@ mod tests { let EventMsg::ItemStarted(started) = started.msg else { panic!("expected item started event"); }; - let TurnItem::WebSearch(started_item) = started.item else { - panic!("expected web search item"); + let TurnItem::Extension(ExtensionItem::WebSearch(started_item)) = started.item else { + panic!("expected extension web search item"); }; - let begin = rx.recv().await.expect("legacy web search begin event"); - let EventMsg::WebSearchBegin(begin) = begin.msg else { - panic!("expected legacy web search begin event"); - }; - let completed = rx.recv().await.expect("item completed event"); - let EventMsg::ItemCompleted(completed) = completed.msg else { - panic!("expected item completed event"); - }; - let TurnItem::WebSearch(completed_item) = completed.item else { - panic!("expected web search item"); - }; - let end = rx.recv().await.expect("legacy web search end event"); - let EventMsg::WebSearchEnd(end) = end.msg else { - panic!("expected legacy web search end event"); - }; - - let expected = WebSearchItem { - id: "call-extension".to_string(), - query: "rust trait object".to_string(), - action: WebSearchAction::Search { - query: Some("rust trait object".to_string()), - queries: None, - }, - }; - assert_eq!(started_item, expected); - assert_eq!(completed_item, expected); - assert_eq!(begin.call_id, expected.id); - assert_eq!(end.call_id, expected.id); - assert_eq!(end.query, expected.query); - assert_eq!(end.action, expected.action); + assert_eq!( + started_item, + WebSearchItem { + id: "call-extension".to_string(), + query: String::new(), + action: None, + } + ); } #[tokio::test] @@ -484,7 +443,7 @@ mod tests { }); codex_tools::TurnItemEmitter::emit_started( &emitter, - ExtensionTurnItem::Extension { + ExtensionTurnItem { item: expected_started_item.clone(), legacy_events: vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { call_id: "call-image".to_string(), @@ -494,7 +453,7 @@ mod tests { .await; codex_tools::TurnItemEmitter::emit_completed( &emitter, - ExtensionTurnItem::Extension { + ExtensionTurnItem { item: expected_completed_item.clone(), legacy_events: vec![EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { call_id: "call-image".to_string(), diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a667cdd53d..e3463d7d1d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -81,8 +81,8 @@ impl EventProcessorWithHumanOutput { "started".style(self.dimmed) ); } - ThreadItem::WebSearch { query, .. } => { - eprintln!("{} {}", "web search:".style(self.bold), query); + ThreadItem::WebSearch(item) => { + eprintln!("{} {}", "web search:".style(self.bold), item.query); } ThreadItem::FileChange { .. } => { eprintln!("{}", "apply patch".style(self.bold)); @@ -196,8 +196,8 @@ impl EventProcessorWithHumanOutput { eprintln!("{}", error.message.style(self.red)); } } - ThreadItem::WebSearch { query, .. } => { - eprintln!("{} {}", "web search:".style(self.bold), query); + ThreadItem::WebSearch(item) => { + eprintln!("{} {}", "web search:".style(self.bold), item.query); } ThreadItem::ContextCompaction { .. } => { eprintln!("{}", "context compacted".style(self.dimmed)); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 79bf88eaaf..fb9325e5b9 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -293,16 +293,12 @@ impl EventProcessorWithJsonOutput { }, }), }), - ThreadItem::WebSearch { - id: raw_id, - query, - action, - } => Some(ExecThreadItem { + ThreadItem::WebSearch(item) => Some(ExecThreadItem { id: make_id(), details: ThreadItemDetails::WebSearch(WebSearchItem { - id: raw_id, - query, - action: match action { + 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")), ) 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 1f44ca31a8..3abdbd716b 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_app_server_protocol::WebSearchItem as ApiWebSearchItem; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; @@ -362,14 +363,14 @@ fn web_search_completion_preserves_query_and_action() { let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: "rust async await".to_string(), action: Some(ApiWebSearchAction::Search { query: Some("rust async await".to_string()), queries: None, }), - }, + }), thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, @@ -403,11 +404,11 @@ fn web_search_start_and_completion_reuse_item_id() { let started = processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: String::new(), action: None, - }, + }), thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, @@ -415,14 +416,14 @@ fn web_search_start_and_completion_reuse_item_id() { let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { - item: ThreadItem::WebSearch { + item: ThreadItem::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: "rust async await".to_string(), action: Some(ApiWebSearchAction::Search { query: Some("rust async await".to_string()), queries: None, }), - }, + }), thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 06a4351a42..3c73aaa5d4 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -101,7 +101,7 @@ fn legacy_end_event(item: &ImageGenerationItem) -> EventMsg { } fn extension_turn_item(item: ImageGenerationItem, legacy_event: EventMsg) -> ExtensionTurnItem { - ExtensionTurnItem::Extension { + ExtensionTurnItem { item: ExtensionItem::ImageGeneration(item), legacy_events: vec![legacy_event], } diff --git a/codex-rs/ext/items/src/lib.rs b/codex-rs/ext/items/src/lib.rs index 27eeb9abb0..2fe91cfac8 100644 --- a/codex-rs/ext/items/src/lib.rs +++ b/codex-rs/ext/items/src/lib.rs @@ -9,6 +9,7 @@ use serde::Serialize; use ts_rs::TS; pub mod image_generation; +pub mod web_search; /// Canonical extension-owned turn item carried through core lifecycle events. /// @@ -34,6 +35,9 @@ pub enum ExtensionItem { #[serde(rename = "image_gen.generation")] #[ts(rename = "image_gen.generation")] ImageGeneration(image_generation::ImageGenerationItem), + #[serde(rename = "web.search")] + #[ts(rename = "web.search")] + WebSearch(web_search::WebSearchItem), } impl ExtensionItem { @@ -42,6 +46,7 @@ impl ExtensionItem { pub fn id(&self) -> &str { match self { Self::ImageGeneration(item) => &item.id, + Self::WebSearch(item) => &item.id, } } } diff --git a/codex-rs/ext/items/src/tests.rs b/codex-rs/ext/items/src/tests.rs index 88575b4bfe..af4dd0c198 100644 --- a/codex-rs/ext/items/src/tests.rs +++ b/codex-rs/ext/items/src/tests.rs @@ -3,6 +3,8 @@ use serde_json::json; use super::ExtensionItem; use super::image_generation::ImageGenerationItem; +use super::web_search::WebSearchAction; +use super::web_search::WebSearchItem; fn completed_image_generation_item() -> ExtensionItem { ExtensionItem::ImageGeneration(ImageGenerationItem { @@ -35,6 +37,37 @@ fn image_generation_item_preserves_stable_wire_shape() { ); } +#[test] +fn web_search_item_preserves_stable_wire_shape() { + let item = ExtensionItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + }); + let value = serde_json::to_value(&item).expect("serialize extension item"); + + assert_eq!( + value, + json!({ + "kind": "web.search", + "id": "search-1", + "query": "docs", + "action": { + "type": "search", + "query": "docs", + "queries": null, + }, + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize extension item"), + item + ); +} + #[test] fn unknown_extension_kind_is_rejected() { let value = json!({ diff --git a/codex-rs/ext/items/src/web_search.rs b/codex-rs/ext/items/src/web_search.rs new file mode 100644 index 0000000000..e878f4e7e2 --- /dev/null +++ b/codex-rs/ext/items/src/web_search.rs @@ -0,0 +1,39 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +// Standalone web-search item owned by the web extension. This is also the +// field-level representation exposed by app-server; core and rollout +// persistence only carry it inside an ExtensionItem envelope. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct WebSearchItem { + pub id: String, + pub query: String, + pub action: Option, +} + +// App-server-facing description of the action performed by standalone web search. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +// Keep app-server's existing v2 TS path. The root WebSearchAction name is +// already used by the snake_case Responses API action type. +#[ts(export_to = "v2/")] +pub enum WebSearchAction { + Search { + query: Option, + queries: Option>, + }, + OpenPage { + url: Option, + }, + FindInPage { + url: Option, + pattern: Option, + }, + #[serde(other)] + Other, +} diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml index d007d6146d..d9ea4b0260 100644 --- a/codex-rs/ext/web-search/Cargo.toml +++ b/codex-rs/ext/web-search/Cargo.toml @@ -16,6 +16,7 @@ workspace = true codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } +codex-extension-items = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 9b8dbf56d6..92d3041e72 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -14,10 +14,15 @@ use codex_extension_api::ToolName; use codex_extension_api::ToolOutput; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema_without_compaction; +use codex_extension_items::ExtensionItem; +use codex_extension_items::web_search::WebSearchAction; +use codex_extension_items::web_search::WebSearchItem; 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_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::WebSearchBeginEvent; +use codex_protocol::protocol::WebSearchEndEvent; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolExposure; @@ -109,14 +114,47 @@ impl WebSearchTool { ), }; call.turn_item_emitter - .emit_started(web_search_item(&call.call_id, WebSearchAction::Other)) + .emit_started(extension_turn_item( + WebSearchItem { + id: call.call_id.clone(), + query: String::new(), + action: None, + }, + EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: call.call_id.clone(), + }), + )) .await; let response = client .search(&request, HeaderMap::new()) .await .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + let legacy_action = match &command_action { + WebSearchAction::Search { query, queries } => CoreWebSearchAction::Search { + query: query.clone(), + queries: queries.clone(), + }, + WebSearchAction::OpenPage { url } => CoreWebSearchAction::OpenPage { url: url.clone() }, + WebSearchAction::FindInPage { url, pattern } => CoreWebSearchAction::FindInPage { + url: url.clone(), + pattern: pattern.clone(), + }, + WebSearchAction::Other => CoreWebSearchAction::Other, + }; + let query = web_search_action_detail(&legacy_action); call.turn_item_emitter - .emit_completed(web_search_item(&call.call_id, command_action)) + .emit_completed(extension_turn_item( + WebSearchItem { + id: call.call_id.clone(), + query: query.clone(), + action: Some(command_action), + }, + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: call.call_id.clone(), + query, + action: legacy_action, + }), + )) .await; Ok(Box::new(SearchOutput::new(response.output))) @@ -180,18 +218,17 @@ fn literal_url(ref_id: &str) -> Option { 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, - }) +fn extension_turn_item(item: WebSearchItem, legacy_event: EventMsg) -> ExtensionTurnItem { + ExtensionTurnItem { + item: ExtensionItem::WebSearch(item), + legacy_events: vec![legacy_event], + } } #[cfg(test)] mod tests { use codex_api::SearchCommands; - use codex_protocol::models::WebSearchAction; + use codex_extension_items::web_search::WebSearchAction; use pretty_assertions::assert_eq; use super::command_action; diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 2c29306cda..2616bb5899 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -47,13 +47,17 @@ pub enum TurnItem { DynamicToolCall(DynamicToolCallItem), CollabAgentToolCall(CollabAgentToolCallItem), SubAgentActivity(SubAgentActivityItem), + /// Hosted Responses API web-search item handled directly by core. + /// + /// Standalone web search uses Self::Extension instead because its display + /// schema is owned by the web-search extension. WebSearch(WebSearchItem), ImageView(ImageViewItem), Sleep(SleepItem), /// Item whose schema and lifecycle details are owned by an extension. /// - /// Standalone image generation uses this path. App-server wraps the same - /// typed item in its public image-generation variant. + /// Standalone image generation and web search use this path. App-server + /// wraps the same typed items in their public variants. Extension(ExtensionItem), /// Hosted Responses API image-generation item handled directly by core. /// diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 8810ea4376..b669014061 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -4,7 +4,6 @@ use crate::ToolPayload; use codex_extension_items::ExtensionItem; use codex_file_system::ExecutorFileSystem; use codex_file_system::FileSystemSandboxContext; -use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_utils_absolute_path::AbsolutePathBuf; @@ -36,8 +35,7 @@ pub type TurnItemEmissionFuture<'a> = Pin + Send + ' /// Visible turn items that an extension may publish into the host lifecycle. #[derive(Clone, Debug)] -pub enum ExtensionTurnItem { - WebSearch(WebSearchItem), +pub struct ExtensionTurnItem { /// Canonical extension item plus compatibility events derived by its owner. /// /// Core intentionally does not inspect extension-owned payloads, so it @@ -45,10 +43,8 @@ pub enum ExtensionTurnItem { /// event first, then these extension-provided events. Core also skips /// global turn-item contributors here so extensions cannot mutate items /// owned by other extensions. - Extension { - item: ExtensionItem, - legacy_events: Vec, - }, + pub item: ExtensionItem, + pub legacy_events: Vec, } /// Host-provided capability for extension tools to emit visible turn items. diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs index 91f8784f6d..96f2c67bcd 100644 --- a/codex-rs/tui/src/app/agent_status_feed.rs +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -178,8 +178,8 @@ fn activity_summary(item: &ThreadItem) -> Option { }; return bounded_summary(&format!("{action} {agent_path}")); } - ThreadItem::WebSearch { query, .. } => { - return bounded_summary(&format!("Web search: {query}")); + ThreadItem::WebSearch(item) => { + return bounded_summary(&format!("Web search: {}", item.query)); } ThreadItem::ImageView { path, .. } => { let path = path.render_for_ui(); diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index df03fb1c74..f60f9c41da 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -290,8 +290,8 @@ impl ChatWidget { self.on_patch_apply_begin(file_update_changes_to_display(changes)); } item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), - ThreadItem::WebSearch { id, .. } => { - self.on_web_search_begin(id); + ThreadItem::WebSearch(item) => { + self.on_web_search_begin(item.id); } ThreadItem::ImageGeneration(_) => { self.on_image_generation_begin(); diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 8b454fa0c4..91cb33c30c 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -139,12 +139,13 @@ impl ChatWidget { .. } => self.on_mcp_tool_call_started(item), item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), - ThreadItem::WebSearch { id, query, action } => { - self.on_web_search_begin(id.clone()); + ThreadItem::WebSearch(item) => { + self.on_web_search_begin(item.id.clone()); self.on_web_search_end( - id, - query, - action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), + item.id, + item.query, + item.action + .unwrap_or(codex_app_server_protocol::WebSearchAction::Other), ); } ThreadItem::ImageView { id: _, path } => { diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index 2e43f40d95..132a9e3c72 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -199,8 +199,8 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option { } => { vec![sub_agent_activity_summary(*kind, agent_path).dim().into()] } - ThreadItem::WebSearch { query, .. } => { - vec![vec!["web search: ".dim(), query.clone().into()].into()] + ThreadItem::WebSearch(item) => { + vec![vec!["web search: ".dim(), item.query.clone().into()].into()] } ThreadItem::ImageView { path, .. } => { let path = path.render_for_ui();