Files
codex/prs/bolinfest/PR-2852.md
2025-09-02 15:17:45 -07:00

20 KiB

PR #2852: Following up on #2371 post commit feedback

Description

  • Introduce websearch end to complement the begin
  • Moves the logic of adding the sebsearch tool to create_tools_json_for_responses_api
  • Making it the client responsibility to toggle the tool on or off
  • Other misc in #2371 post commit feedback
  • Show the query:
image

Full Diff

diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
index 641574074e..6eca119f3e 100644
--- a/codex-rs/core/src/chat_completions.rs
+++ b/codex-rs/core/src/chat_completions.rs
@@ -129,7 +129,9 @@ pub(crate) async fn stream_chat_completions(
                     "content": output,
                 }));
             }
-            ResponseItem::Reasoning { .. } | ResponseItem::Other => {
+            ResponseItem::Reasoning { .. }
+            | ResponseItem::WebSearchCall { .. }
+            | ResponseItem::Other => {
                 // Omit these items from the conversation history.
                 continue;
             }
@@ -623,11 +625,8 @@ where
                 Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => {
                     continue;
                 }
-                Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { .. }))) => {
-                    return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin {
-                        call_id: String::new(),
-                        query: None,
-                    })));
+                Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))) => {
+                    return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id })));
                 }
             }
         }
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index e0ab334fcf..b7bfeaa7ca 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -160,21 +160,7 @@ impl ModelClient {
         let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
 
         let full_instructions = prompt.get_full_instructions(&self.config.model_family);
-        let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
-        // ChatGPT backend expects the preview name for web search.
-        if auth_mode == Some(AuthMode::ChatGPT) {
-            for tool in &mut tools_json {
-                if let Some(map) = tool.as_object_mut()
-                    && map.get("type").and_then(|v| v.as_str()) == Some("web_search")
-                {
-                    map.insert(
-                        "type".to_string(),
-                        serde_json::Value::String("web_search_preview".to_string()),
-                    );
-                }
-            }
-        }
-
+        let tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
         let reasoning = create_reasoning_param_for_request(
             &self.config.model_family,
             self.effort,
@@ -607,11 +593,9 @@ async fn process_sse<S>(
             | "response.custom_tool_call_input.delta"
             | "response.custom_tool_call_input.done" // also emitted as response.output_item.done
             | "response.in_progress"
-            | "response.output_item.added"
-            | "response.output_text.done" => {
-                if event.kind == "response.output_item.added"
-                    && let Some(item) = event.item.as_ref()
-                {
+            | "response.output_text.done" => {}
+            "response.output_item.added" => {
+                if let Some(item) = event.item.as_ref() {
                     // Detect web_search_call begin and forward a synthetic event upstream.
                     if let Some(ty) = item.get("type").and_then(|v| v.as_str())
                         && ty == "web_search_call"
@@ -621,7 +605,7 @@ async fn process_sse<S>(
                             .and_then(|v| v.as_str())
                             .unwrap_or("")
                             .to_string();
-                        let ev = ResponseEvent::WebSearchCallBegin { call_id, query: None };
+                        let ev = ResponseEvent::WebSearchCallBegin { call_id };
                         if tx_event.send(Ok(ev)).await.is_err() {
                             return;
                         }
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
index e2c191f4a5..2740105100 100644
--- a/codex-rs/core/src/client_common.rs
+++ b/codex-rs/core/src/client_common.rs
@@ -95,7 +95,6 @@ pub enum ResponseEvent {
     ReasoningSummaryPartAdded,
     WebSearchCallBegin {
         call_id: String,
-        query: Option<String>,
     },
 }
 
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 365969ac02..3df69c8590 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -100,6 +100,7 @@ use crate::protocol::Submission;
 use crate::protocol::TaskCompleteEvent;
 use crate::protocol::TurnDiffEvent;
 use crate::protocol::WebSearchBeginEvent;
+use crate::protocol::WebSearchEndEvent;
 use crate::rollout::RolloutRecorder;
 use crate::safety::SafetyCheck;
 use crate::safety::assess_command_safety;
@@ -118,6 +119,7 @@ use codex_protocol::models::ReasoningItemReasoningSummary;
 use codex_protocol::models::ResponseInputItem;
 use codex_protocol::models::ResponseItem;
 use codex_protocol::models::ShellToolCallParams;
+use codex_protocol::models::WebSearchAction;
 
 // A convenience extension trait for acquiring mutex locks where poisoning is
 // unrecoverable and should abort the program. This avoids scattered `.unwrap()`
@@ -1746,13 +1748,12 @@ async fn try_run_turn(
                 .await?;
                 output.push(ProcessedResponseItem { item, response });
             }
-            ResponseEvent::WebSearchCallBegin { call_id, query } => {
-                let q = query.unwrap_or_else(|| "Searching Web...".to_string());
+            ResponseEvent::WebSearchCallBegin { call_id } => {
                 let _ = sess
                     .tx_event
                     .send(Event {
                         id: sub_id.to_string(),
-                        msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: q }),
+                        msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id }),
                     })
                     .await;
             }
@@ -2048,6 +2049,17 @@ async fn handle_response_item(
             debug!("unexpected CustomToolCallOutput from stream");
             None
         }
+        ResponseItem::WebSearchCall { id, action, .. } => {
+            if let WebSearchAction::Search { query } = action {
+                let call_id = id.unwrap_or_else(|| "".to_string());
+                let event = Event {
+                    id: sub_id.to_string(),
+                    msg: EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query }),
+                };
+                sess.tx_event.send(event).await.ok();
+            }
+            None
+        }
         ResponseItem::Other => None,
     };
     Ok(output)
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index 4d623c3e5b..a39756a9cf 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -497,7 +497,6 @@ pub struct ProjectConfig {
 
 #[derive(Deserialize, Debug, Clone, Default)]
 pub struct ToolsToml {
-    // Renamed from `web_search_request`; keep alias for backwards compatibility.
     #[serde(default, alias = "web_search_request")]
     pub web_search: Option<bool>,
 
@@ -663,7 +662,7 @@ impl Config {
             })?
             .clone();
 
-        let shell_environment_policy = cfg.shell_environment_policy.clone().into();
+        let shell_environment_policy = cfg.shell_environment_policy.into();
 
         let resolved_cwd = {
             use std::env;
@@ -684,7 +683,7 @@ impl Config {
             }
         };
 
-        let history = cfg.history.clone().unwrap_or_default();
+        let history = cfg.history.unwrap_or_default();
 
         let tools_web_search_request = override_tools_web_search_request
             .or(cfg.tools.as_ref().and_then(|t| t.web_search))
@@ -766,7 +765,7 @@ impl Config {
             codex_home,
             history,
             file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
-            tui: cfg.tui.clone().unwrap_or_default(),
+            tui: cfg.tui.unwrap_or_default(),
             codex_linux_sandbox_exe,
 
             hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
@@ -785,7 +784,7 @@ impl Config {
             model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity),
             chatgpt_base_url: config_profile
                 .chatgpt_base_url
-                .or(cfg.chatgpt_base_url.clone())
+                .or(cfg.chatgpt_base_url)
                 .unwrap_or("https://chatgpt.com/backend-api/".to_string()),
 
             experimental_resume,
diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs
index a8995f57f7..ed15bd4155 100644
--- a/codex-rs/core/src/conversation_history.rs
+++ b/codex-rs/core/src/conversation_history.rs
@@ -72,7 +72,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
         | ResponseItem::CustomToolCallOutput { .. }
         | ResponseItem::LocalShellCall { .. }
         | ResponseItem::Reasoning { .. } => true,
-        ResponseItem::Other => false,
+        ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
     }
 }
 
diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs
index f74188162c..59ca7a8361 100644
--- a/codex-rs/core/src/openai_tools.rs
+++ b/codex-rs/core/src/openai_tools.rs
@@ -47,7 +47,9 @@ pub(crate) enum OpenAiTool {
     Function(ResponsesApiTool),
     #[serde(rename = "local_shell")]
     LocalShell {},
-    #[serde(rename = "web_search")]
+    // TODO: Understand why we get an error on web_search although the API docs say it's supported.
+    // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
+    #[serde(rename = "web_search_preview")]
     WebSearch {},
     #[serde(rename = "custom")]
     Freeform(FreeformTool),
@@ -335,12 +337,12 @@ pub fn create_tools_json_for_responses_api(
     let mut tools_json = Vec::new();
 
     for tool in tools {
-        tools_json.push(serde_json::to_value(tool)?);
+        let json = serde_json::to_value(tool)?;
+        tools_json.push(json);
     }
 
     Ok(tools_json)
 }
-
 /// Returns JSON values that are compatible with Function Calling in the
 /// Chat Completions API:
 /// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
@@ -702,8 +704,8 @@ mod tests {
                                     "number_property": { "type": "number" },
                                 },
                                 "required": [
-                                    "string_property".to_string(),
-                                    "number_property".to_string()
+                                    "string_property",
+                                    "number_property",
                                 ],
                                 "additionalProperties": Some(false),
                             },
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
index 46098c1637..3c7162e883 100644
--- a/codex-rs/core/src/rollout.rs
+++ b/codex-rs/core/src/rollout.rs
@@ -135,7 +135,7 @@ impl RolloutRecorder {
                 | ResponseItem::CustomToolCall { .. }
                 | ResponseItem::CustomToolCallOutput { .. }
                 | ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
-                ResponseItem::Other => {
+                ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {
                     // These should never be serialized.
                     continue;
                 }
@@ -199,7 +199,7 @@ impl RolloutRecorder {
                     | ResponseItem::CustomToolCall { .. }
                     | ResponseItem::CustomToolCallOutput { .. }
                     | ResponseItem::Reasoning { .. } => items.push(item),
-                    ResponseItem::Other => {}
+                    ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
                 },
                 Err(e) => {
                     warn!("failed to parse item: {v:?}, error: {e}");
@@ -326,7 +326,7 @@ async fn rollout_writer(
                         | ResponseItem::Reasoning { .. } => {
                             writer.write_line(&item).await?;
                         }
-                        ResponseItem::Other => {}
+                        ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
                     }
                 }
             }
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 a3558f7407..6b680ddea9 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -25,6 +25,7 @@ use codex_core::protocol::TaskCompleteEvent;
 use codex_core::protocol::TurnAbortReason;
 use codex_core::protocol::TurnDiffEvent;
 use codex_core::protocol::WebSearchBeginEvent;
+use codex_core::protocol::WebSearchEndEvent;
 use owo_colors::OwoColorize;
 use owo_colors::Style;
 use shlex::try_join;
@@ -362,8 +363,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
                     }
                 }
             }
-            EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _, query }) => {
-                ts_println!(self, "🌐 {query}");
+            EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {}
+            EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
+                ts_println!(self, "🌐 Searched: {query}");
             }
             EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
                 call_id,
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
index 3a26c26cd3..2f2245cc63 100644
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
@@ -273,6 +273,7 @@ async fn run_codex_tool_session_inner(
                     | EventMsg::PatchApplyEnd(_)
                     | EventMsg::TurnDiff(_)
                     | EventMsg::WebSearchBegin(_)
+                    | EventMsg::WebSearchEnd(_)
                     | EventMsg::GetHistoryEntryResponse(_)
                     | EventMsg::PlanUpdate(_)
                     | EventMsg::TurnAborted(_)
diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs
index 31e61d8be3..6cdfd933b1 100644
--- a/codex-rs/protocol/src/models.rs
+++ b/codex-rs/protocol/src/models.rs
@@ -95,6 +95,22 @@ pub enum ResponseItem {
         call_id: String,
         output: String,
     },
+    // Emitted by the Responses API when the agent triggers a web search.
+    // Example payload (from SSE `response.output_item.done`):
+    // {
+    //   "id":"ws_...",
+    //   "type":"web_search_call",
+    //   "status":"completed",
+    //   "action": {"type":"search","query":"weather: San Francisco, CA"}
+    // }
+    WebSearchCall {
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        id: Option<String>,
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        status: Option<String>,
+        action: WebSearchAction,
+    },
+
     #[serde(other)]
     Other,
 }
@@ -162,6 +178,16 @@ pub struct LocalShellExecAction {
     pub user: Option<String>,
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum WebSearchAction {
+    Search {
+        query: String,
+    },
+    #[serde(other)]
+    Other,
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ReasoningItemReasoningSummary {
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index 7f317bbfba..464dcf274c 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -439,6 +439,8 @@ pub enum EventMsg {
 
     WebSearchBegin(WebSearchBeginEvent),
 
+    WebSearchEnd(WebSearchEndEvent),
+
     /// Notification that the server is about to execute a command.
     ExecCommandBegin(ExecCommandBeginEvent),
 
@@ -668,6 +670,11 @@ impl McpToolCallEndEvent {
 #[derive(Debug, Clone, Deserialize, Serialize)]
 pub struct WebSearchBeginEvent {
     pub call_id: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct WebSearchEndEvent {
+    pub call_id: String,
     pub query: String,
 }
 
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index e687fc038f..2fb09900ed 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -30,6 +30,7 @@ use codex_core::protocol::TokenUsage;
 use codex_core::protocol::TurnAbortReason;
 use codex_core::protocol::TurnDiffEvent;
 use codex_core::protocol::WebSearchBeginEvent;
+use codex_core::protocol::WebSearchEndEvent;
 use codex_protocol::parse_command::ParsedCommand;
 use crossterm::event::KeyCode;
 use crossterm::event::KeyEvent;
@@ -355,9 +356,16 @@ impl ChatWidget {
         self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2));
     }
 
-    fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) {
+    fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) {
         self.flush_answer_stream_with_separator();
-        self.add_to_history(history_cell::new_web_search_call(ev.query));
+    }
+
+    fn on_web_search_end(&mut self, ev: WebSearchEndEvent) {
+        self.flush_answer_stream_with_separator();
+        self.add_to_history(history_cell::new_web_search_call(format!(
+            "Searched: {}",
+            ev.query
+        )));
     }
 
     fn on_get_history_entry_response(
@@ -969,6 +977,7 @@ impl ChatWidget {
             EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
             EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
             EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
+            EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
             EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
             EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
             EventMsg::ShutdownComplete => self.on_shutdown_complete(),

Review Comments

codex-rs/core/src/client_common.rs

@@ -95,7 +95,6 @@ pub enum ResponseEvent {
     ReasoningSummaryPartAdded,
     WebSearchCallBegin {
         call_id: String,
-        query: Option<String>,

Should we keep this around? Might the UI want this?

codex-rs/core/src/codex.rs

@@ -2048,6 +2049,17 @@ async fn handle_response_item(
             debug!("unexpected CustomToolCallOutput from stream");
             None
         }
+        ResponseItem::WebSearchCall { id, action, .. } => {
+            if let WebSearchAction::Search { query } = action {
+                let call_id = id.unwrap_or_else(|| "".to_string());

Is this OK or a logical error? Can we make this a stronger check?

codex-rs/protocol/src/models.rs

@@ -95,6 +95,22 @@ pub enum ResponseItem {
         call_id: String,
         output: String,
     },
+    // Emitted by the Responses API when the agent triggers a web search.
+    // Example payload (from SSE `response.output_item.done`):
+    // {
+    //   "id":"ws_...",
+    //   "type":"web_search_call",
+    //   "status":"completed",
+    //   "action": {"type":"search","query":"weather: San Francisco, CA"}
+    // }
+    WebSearchCall {
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        id: Option<String>,

So this is not reliably supplied?