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.
This commit is contained in:
Owen Lin
2026-07-08 09:40:00 -07:00
committed by GitHub
parent e212cc95b0
commit a219b6fdb4
26 changed files with 279 additions and 209 deletions
+1
View File
@@ -4471,6 +4471,7 @@ dependencies = [
"codex-api",
"codex-core",
"codex-extension-api",
"codex-extension-items",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
@@ -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(),
+8 -8
View File
@@ -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<TrackEventRequest> {
},
))
}
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<TrackEventRequest> {
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()),
},
}))
}
@@ -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, };
+1
View File
@@ -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";
@@ -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<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "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, };
@@ -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(),
@@ -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<WebSearchAction>,
},
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<GuardianApprovalReviewAction> 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<String>,
queries: Option<Vec<String>>,
},
OpenPage {
url: Option<String>,
},
FindInPage {
url: Option<String>,
pattern: Option<String>,
},
#[serde(other)]
Other,
}
impl From<codex_protocol::models::WebSearchAction> 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<CoreTurnItem> 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<CoreTurnItem> 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 {
@@ -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 {
@@ -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<ItemStar
.params
.context("item/started notification should include params")?,
)?;
if matches!(&started.item, ThreadItem::WebSearch { .. }) {
if matches!(&started.item, ThreadItem::WebSearch(_)) {
return Ok(started);
}
}
@@ -258,7 +259,7 @@ async fn wait_for_web_search_completed(
.params
.context("item/completed notification should include params")?,
)?;
if matches!(&completed.item, ThreadItem::WebSearch { .. }) {
if matches!(&completed.item, ThreadItem::WebSearch(_)) {
return Ok(completed);
}
}
@@ -17,8 +17,6 @@ use codex_tools::TurnItemEmitter;
use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::stream_events_utils::TurnItemContributorPolicy;
use crate::stream_events_utils::finalize_turn_item;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::apply_granted_turn_permissions;
@@ -87,13 +85,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::WebSearch(item) => (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<Box<dyn codex_tools::ToolOutput>, 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(),
@@ -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));
@@ -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")),
)
@@ -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,
+1 -1
View File
@@ -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],
}
+5
View File
@@ -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,
}
}
}
+33
View File
@@ -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::<ExtensionItem>(value).expect("deserialize extension item"),
item
);
}
#[test]
fn unknown_extension_kind_is_rejected() {
let value = json!({
+39
View File
@@ -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<WebSearchAction>,
}
// 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<String>,
queries: Option<Vec<String>>,
},
OpenPage {
url: Option<String>,
},
FindInPage {
url: Option<String>,
pattern: Option<String>,
},
#[serde(other)]
Other,
}
+1
View File
@@ -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 }
+48 -11
View File
@@ -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<String> {
Url::parse(ref_id).is_ok().then(|| ref_id.to_string())
}
fn web_search_item(call_id: &str, action: WebSearchAction) -> ExtensionTurnItem {
ExtensionTurnItem::WebSearch(WebSearchItem {
id: call_id.to_string(),
query: web_search_action_detail(&action),
action,
})
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;
+6 -2
View File
@@ -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.
///
+3 -7
View File
@@ -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<Box<dyn Future<Output = ()> + 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<EventMsg>,
},
pub item: ExtensionItem,
pub legacy_events: Vec<EventMsg>,
}
/// Host-provided capability for extension tools to emit visible turn items.
+2 -2
View File
@@ -178,8 +178,8 @@ fn activity_summary(item: &ThreadItem) -> Option<String> {
};
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();
+2 -2
View File
@@ -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();
+6 -5
View File
@@ -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 } => {
+2 -2
View File
@@ -199,8 +199,8 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
} => {
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();