diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d38a4dd435..f99659cd4d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2121,7 +2121,11 @@ pub enum ThreadItem { }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - WebSearch { id: String, query: String }, + WebSearch { + id: String, + query: String, + action: Option, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ImageView { id: String, path: String }, @@ -2136,6 +2140,42 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +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, + } + } +} + impl From for ThreadItem { fn from(value: CoreTurnItem) -> Self { match value { @@ -2165,6 +2205,7 @@ impl From for ThreadItem { CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { id: search.id, query: search.query, + action: Some(WebSearchAction::from(search.action)), }, CoreTurnItem::ContextCompaction(compaction) => { ThreadItem::ContextCompaction { id: compaction.id } @@ -2818,7 +2859,7 @@ mod tests { use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; - use codex_protocol::models::WebSearchAction; + use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::user_input::UserInput as CoreUserInput; use pretty_assertions::assert_eq; @@ -2934,8 +2975,9 @@ mod tests { let search_item = TurnItem::WebSearch(WebSearchItem { id: "search-1".to_string(), query: "docs".to_string(), - action: WebSearchAction::Search { + action: CoreWebSearchAction::Search { query: Some("docs".to_string()), + queries: None, }, }); @@ -2944,6 +2986,10 @@ mod tests { ThreadItem::WebSearch { id: "search-1".to_string(), query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8a63ba323f..1963c0cbdf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -450,7 +450,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, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. -- `webSearch` — `{id, query}` for a web search request issued by the agent. +- `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. - `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. - `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description. - `exitedReviewMode` — `{id, review}` emitted when the reviewer finishes; `review` is the full plain-text review (usually, overall notes plus bullet point findings). diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 32382aad71..35804fd47c 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -426,6 +426,7 @@ mod tests { status: Some("completed".to_string()), action: Some(WebSearchAction::Search { query: Some("weather".to_string()), + queries: None, }), }; @@ -439,6 +440,7 @@ mod tests { query: "weather".to_string(), action: WebSearchAction::Search { query: Some("weather".to_string()), + queries: None, }, } ), diff --git a/codex-rs/core/src/web_search.rs b/codex-rs/core/src/web_search.rs index 458758b70e..d3c895c5fa 100644 --- a/codex-rs/core/src/web_search.rs +++ b/codex-rs/core/src/web_search.rs @@ -1,8 +1,23 @@ use codex_protocol::models::WebSearchAction; +fn search_action_detail(query: &Option, queries: &Option>) -> String { + query.clone().filter(|q| !q.is_empty()).unwrap_or_else(|| { + let items = queries.as_ref(); + let first = items + .and_then(|queries| queries.first()) + .cloned() + .unwrap_or_default(); + if items.is_some_and(|queries| queries.len() > 1) && !first.is_empty() { + format!("{first} ...") + } else { + first + } + }) +} + pub fn web_search_action_detail(action: &WebSearchAction) -> String { match action { - WebSearchAction::Search { query } => query.clone().unwrap_or_default(), + WebSearchAction::Search { query, queries } => search_action_detail(query, queries), WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(), WebSearchAction::FindInPage { url, pattern } => match (pattern, url) { (Some(pattern), Some(url)) => format!("'{pattern}' in {url}"), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 72608a44d8..150513d000 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1216,6 +1216,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { status: Some("completed".into()), action: Some(WebSearchAction::Search { query: Some("weather".into()), + queries: None, }), }); prompt.input.push(ResponseItem::FunctionCall { diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 7ef73dce94..842122dfea 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -255,6 +255,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { completed.action, WebSearchAction::Search { query: Some("weather seattle".to_string()), + queries: 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 e405195e7e..47b7a9f331 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -131,6 +131,7 @@ fn web_search_end_emits_item_completed() { let query = "rust async await".to_string(); let action = WebSearchAction::Search { query: Some(query.clone()), + queries: None, }; let out = ep.collect_thread_events(&event( "w1", @@ -195,6 +196,7 @@ fn web_search_begin_then_end_reuses_item_id() { }; let action = WebSearchAction::Search { query: Some("rust async await".to_string()), + queries: None, }; let end = ep.collect_thread_events(&event( "w1", diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 759ff5aae4..8e67fc40fc 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -620,6 +620,9 @@ pub enum WebSearchAction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + queries: Option>, }, OpenPage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1132,12 +1135,14 @@ mod tests { "status": "completed", "action": { "type": "search", - "query": "weather seattle" + "query": "weather seattle", + "queries": ["weather seattle", "seattle weather now"] } }"#, None, Some(WebSearchAction::Search { query: Some("weather seattle".into()), + queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]), }), Some("completed".into()), true, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 929d7d73c7..6a6939620f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2469,6 +2469,7 @@ mod tests { query: "find docs".into(), action: WebSearchAction::Search { query: Some("find docs".into()), + queries: None, }, }), }; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 39482bc1f3..74fc650908 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2294,7 +2294,10 @@ mod tests { let cell = new_web_search_call( "call-1".to_string(), query.clone(), - WebSearchAction::Search { query: Some(query) }, + WebSearchAction::Search { + query: Some(query), + queries: None, + }, ); let rendered = render_lines(&cell.display_lines(64)).join("\n"); @@ -2308,7 +2311,10 @@ mod tests { let cell = new_web_search_call( "call-1".to_string(), query.clone(), - WebSearchAction::Search { query: Some(query) }, + WebSearchAction::Search { + query: Some(query), + queries: None, + }, ); let rendered = render_lines(&cell.display_lines(64)); @@ -2327,7 +2333,10 @@ mod tests { let cell = new_web_search_call( "call-1".to_string(), query.clone(), - WebSearchAction::Search { query: Some(query) }, + WebSearchAction::Search { + query: Some(query), + queries: None, + }, ); let rendered = render_lines(&cell.display_lines(64)); @@ -2341,7 +2350,10 @@ mod tests { let cell = new_web_search_call( "call-1".to_string(), query.clone(), - WebSearchAction::Search { query: Some(query) }, + WebSearchAction::Search { + query: Some(query), + queries: None, + }, ); let rendered = render_lines(&cell.transcript_lines(64)).join("\n");