Add client-only web search result metadata

This commit is contained in:
Alexi Christakis
2026-07-07 19:20:41 -07:00
parent aaa30f79c2
commit 3c6ad5ea3d
39 changed files with 831 additions and 3 deletions
@@ -4404,6 +4404,7 @@ async fn turn_event_counts_completed_tool_items() {
id: "web-1".to_string(),
query: "codex".to_string(),
action: None,
results: None,
},
ThreadItem::ImageGeneration {
id: "image-1".to_string(),
+3 -1
View File
@@ -2027,7 +2027,9 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
))
}
ThreadItem::WebSearch { id, query, action } => {
ThreadItem::WebSearch {
id, query, action, ..
} => {
let base = tool_item_base(
thread_id,
turn_id,
@@ -4511,6 +4511,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -5684,6 +5693,29 @@
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"properties": {
"error": {
@@ -18357,6 +18357,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/v2/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -20829,6 +20838,29 @@
],
"type": "string"
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
@@ -16136,6 +16136,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -18608,6 +18617,29 @@
],
"type": "string"
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"WebSearchToolConfig": {
"additionalProperties": false,
"properties": {
@@ -1102,6 +1102,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1524,6 +1533,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1102,6 +1102,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1524,6 +1533,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1799,6 +1808,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1775,6 +1775,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2405,6 +2414,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2196,6 +2205,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2196,6 +2205,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2196,6 +2205,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1775,6 +1775,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2431,6 +2440,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2196,6 +2205,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1775,6 +1775,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2405,6 +2414,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2196,6 +2205,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1566,6 +1566,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -2196,6 +2205,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1799,6 +1808,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1799,6 +1808,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -1247,6 +1247,15 @@
"query": {
"type": "string"
},
"results": {
"items": {
"$ref": "#/definitions/WebSearchResult"
},
"type": [
"array",
"null"
]
},
"type": {
"enum": [
"webSearch"
@@ -1799,6 +1808,29 @@
"type": "object"
}
]
},
"WebSearchResult": {
"properties": {
"snippet": {
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
}
},
"properties": {
@@ -25,6 +25,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus";
import type { SubAgentActivityKind } from "./SubAgentActivityKind";
import type { UserInput } from "./UserInput";
import type { WebSearchAction } from "./WebSearchAction";
import type { WebSearchResult } from "./WebSearchResult";
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 +106,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", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "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", id: string, query: string, action: WebSearchAction | null, results: Array<WebSearchResult> | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -0,0 +1,5 @@
// 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.
export type WebSearchResult = { url: string, title: string | null, snippet: string | null, };
@@ -499,6 +499,7 @@ export type { TurnsPage } from "./TurnsPage";
export type { UserInput } from "./UserInput";
export type { WarningNotification } from "./WarningNotification";
export type { WebSearchAction } from "./WebSearchAction";
export type { WebSearchResult } from "./WebSearchResult";
export type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness";
export type { WindowsSandboxReadinessResponse } from "./WindowsSandboxReadinessResponse";
export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification";
@@ -22,6 +22,7 @@ use crate::protocol::v2::TurnItemsView;
use crate::protocol::v2::TurnStatus;
use crate::protocol::v2::UserInput;
use crate::protocol::v2::WebSearchAction;
use crate::protocol::v2::WebSearchResult;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentReasoningEvent;
@@ -615,6 +616,7 @@ impl ThreadHistoryBuilder {
id: payload.call_id.clone(),
query: String::new(),
action: None,
results: None,
};
self.upsert_item_in_current_turn(item);
}
@@ -624,6 +626,10 @@ impl ThreadHistoryBuilder {
id: payload.call_id.clone(),
query: payload.query.clone(),
action: Some(WebSearchAction::from(payload.action.clone())),
results: payload
.results
.clone()
.map(|results| results.into_iter().map(WebSearchResult::from).collect()),
};
self.upsert_item_in_current_turn(item);
}
@@ -1556,6 +1562,7 @@ mod tests {
use codex_protocol::items::SleepItem as CoreSleepItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem as CoreUserMessageItem;
use codex_protocol::items::WebSearchResult as CoreWebSearchResult;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::ImageDetail;
@@ -2443,6 +2450,11 @@ mod tests {
query: Some("codex".into()),
queries: None,
},
results: Some(vec![CoreWebSearchResult {
url: "https://example.com/codex".into(),
title: Some("Codex".into()),
snippet: Some("Codex documentation".into()),
}]),
}),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "exec-1".into(),
@@ -2499,6 +2511,11 @@ mod tests {
query: Some("codex".into()),
queries: None,
}),
results: Some(vec![WebSearchResult {
url: "https://example.com/codex".into(),
title: Some("Codex".into()),
snippet: Some("Codex documentation".into()),
}]),
}
);
assert_eq!(
@@ -3917,6 +3934,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
},
results: None,
}),
));
@@ -3932,6 +3950,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
}),
results: None,
},
}],
changed_turns: Vec::new(),
@@ -4052,6 +4071,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
},
results: None,
})),
]);
@@ -4067,6 +4087,7 @@ mod tests {
query: Some("codex".into()),
queries: None,
}),
results: None,
},
}],
changed_turns: vec![ThreadHistoryTurnChange {
@@ -359,6 +359,7 @@ pub enum ThreadItem {
id: String,
query: String,
action: Option<WebSearchAction>,
results: Option<Vec<WebSearchResult>>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -783,6 +784,25 @@ impl TryFrom<GuardianApprovalReviewAction> for CoreGuardianAssessmentAction {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WebSearchResult {
pub url: String,
pub title: Option<String>,
pub snippet: Option<String>,
}
impl From<codex_protocol::items::WebSearchResult> for WebSearchResult {
fn from(value: codex_protocol::items::WebSearchResult) -> Self {
Self {
url: value.url,
title: value.title,
snippet: value.snippet,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase")]
@@ -922,6 +942,9 @@ impl From<CoreTurnItem> for ThreadItem {
id: search.id,
query: search.query,
action: Some(WebSearchAction::from(search.action)),
results: search
.results
.map(|results| results.into_iter().map(Into::into).collect()),
},
CoreTurnItem::ImageView(image) => ThreadItem::ImageView {
id: image.id,
@@ -20,6 +20,7 @@ use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::items::WebSearchResult as CoreWebSearchResult;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
@@ -2777,6 +2778,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
query: Some("docs".to_string()),
queries: None,
},
results: Some(vec![CoreWebSearchResult {
url: "https://openai.com/docs".to_string(),
title: Some("OpenAI docs".to_string()),
snippet: Some("Documentation".to_string()),
}]),
});
assert_eq!(
@@ -2788,6 +2794,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
query: Some("docs".to_string()),
queries: None,
}),
results: Some(vec![WebSearchResult {
url: "https://openai.com/docs".to_string(),
title: Some("OpenAI docs".to_string()),
snippet: Some("Documentation".to_string()),
}]),
}
);
@@ -187,6 +187,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
id: call_id.to_string(),
query: String::new(),
action: Some(WebSearchAction::Other),
results: None,
}
);
let expected_completed_item = ThreadItem::WebSearch {
@@ -196,6 +197,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
query: Some("standalone web search".to_string()),
queries: None,
}),
results: None,
};
assert_eq!(completed.item, expected_completed_item);
+1
View File
@@ -204,6 +204,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
id: id.clone().unwrap_or_default(),
query,
action,
results: None,
}))
}
ResponseItem::ImageGenerationCall {
+4
View File
@@ -508,6 +508,7 @@ fn parses_web_search_call() {
query: Some("weather".to_string()),
queries: None,
},
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -536,6 +537,7 @@ fn parses_web_search_open_page_call() {
action: WebSearchAction::OpenPage {
url: Some("https://example.com".to_string()),
},
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -566,6 +568,7 @@ fn parses_web_search_find_in_page_call() {
url: Some("https://example.com".to_string()),
pattern: Some("needle".to_string()),
},
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -589,6 +592,7 @@ fn parses_partial_web_search_call_without_action_as_other() {
id: "ws_partial".to_string(),
query: String::new(),
action: WebSearchAction::Other,
results: None,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
@@ -273,6 +273,7 @@ mod tests {
query: Some("rust trait object".to_string()),
queries: None,
},
results: None,
});
call.turn_item_emitter.emit_started(item.clone()).await;
call.turn_item_emitter.emit_completed(item).await;
@@ -438,6 +439,7 @@ mod tests {
query: Some("rust trait object".to_string()),
queries: None,
},
results: None,
};
assert_eq!(started_item, expected);
assert_eq!(completed_item, expected);
@@ -487,6 +489,7 @@ mod tests {
id: "search-1".to_string(),
query: "contributors".to_string(),
action: WebSearchAction::Other,
results: None,
}),
)
.await;
@@ -54,6 +54,7 @@ use crate::exec_events::TurnFailedEvent;
use crate::exec_events::TurnStartedEvent;
use crate::exec_events::Usage;
use crate::exec_events::WebSearchItem;
use crate::exec_events::WebSearchResult;
pub struct EventProcessorWithJsonOutput {
last_message_path: Option<PathBuf>,
@@ -297,6 +298,7 @@ impl EventProcessorWithJsonOutput {
id: raw_id,
query,
action,
results,
} => Some(ExecThreadItem {
id: make_id(),
details: ThreadItemDetails::WebSearch(WebSearchItem {
@@ -309,6 +311,16 @@ impl EventProcessorWithJsonOutput {
.unwrap_or(WebSearchAction::Other),
None => WebSearchAction::Other,
},
results: results.map(|results| {
results
.into_iter()
.map(|result| WebSearchResult {
url: result.url,
title: result.title,
snippet: result.snippet,
})
.collect()
}),
}),
}),
_ => None,
+14
View File
@@ -296,6 +296,20 @@ pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: WebSearchAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub results: Option<Vec<WebSearchResult>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct WebSearchResult {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub snippet: Option<String>,
}
/// An error notification.
+1
View File
@@ -138,6 +138,7 @@ pub use exec_events::TurnFailedEvent;
pub use exec_events::TurnStartedEvent;
pub use exec_events::Usage;
pub use exec_events::WebSearchItem;
pub use exec_events::WebSearchResult;
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
@@ -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::WebSearchResult as ApiWebSearchResult;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
@@ -74,6 +75,7 @@ use codex_exec::TurnFailedEvent;
use codex_exec::TurnStartedEvent;
use codex_exec::Usage;
use codex_exec::WebSearchItem;
use codex_exec::WebSearchResult;
#[test]
fn map_todo_items_preserves_text_and_completion_state() {
@@ -369,6 +371,11 @@ fn web_search_completion_preserves_query_and_action() {
query: Some("rust async await".to_string()),
queries: None,
}),
results: Some(vec![ApiWebSearchResult {
url: "https://example.com/rust-async".to_string(),
title: Some("Async Rust".to_string()),
snippet: Some("An introduction to async Rust.".to_string()),
}]),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -389,6 +396,11 @@ fn web_search_completion_preserves_query_and_action() {
query: Some("rust async await".to_string()),
queries: None,
},
results: Some(vec![WebSearchResult {
url: "https://example.com/rust-async".to_string(),
title: Some("Async Rust".to_string()),
snippet: Some("An introduction to async Rust.".to_string()),
}]),
}),
},
})],
@@ -407,6 +419,7 @@ fn web_search_start_and_completion_reuse_item_id() {
id: "search-1".to_string(),
query: String::new(),
action: None,
results: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -422,6 +435,7 @@ fn web_search_start_and_completion_reuse_item_id() {
query: Some("rust async await".to_string()),
queries: None,
}),
results: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -439,6 +453,7 @@ fn web_search_start_and_completion_reuse_item_id() {
id: "search-1".to_string(),
query: String::new(),
action: WebSearchAction::Other,
results: None,
}),
},
})],
@@ -458,6 +473,7 @@ fn web_search_start_and_completion_reuse_item_id() {
query: Some("rust async await".to_string()),
queries: None,
},
results: None,
}),
},
})],
+1
View File
@@ -185,6 +185,7 @@ fn web_search_item(call_id: &str, action: WebSearchAction) -> ExtensionTurnItem
id: call_id.to_string(),
query: web_search_action_detail(&action),
action,
results: None,
})
}
+120
View File
@@ -22,6 +22,7 @@ use crate::user_input::TextElement;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_string::take_bytes_at_char_boundary;
use quick_xml::de::from_str as from_xml_str;
use quick_xml::se::to_string as to_xml_string;
use schemars::JsonSchema;
@@ -262,11 +263,97 @@ pub struct SubAgentActivityItem {
pub agent_path: AgentPath,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct WebSearchResult {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub snippet: Option<String>,
}
const MAX_WEB_SEARCH_RESULTS: usize = 20;
const MAX_WEB_SEARCH_RESULT_URL_BYTES: usize = 512;
const MAX_WEB_SEARCH_RESULT_TOTAL_URL_BYTES: usize = 2_048;
const MAX_WEB_SEARCH_RESULT_TITLE_BYTES: usize = 256;
const MAX_WEB_SEARCH_RESULT_SNIPPET_BYTES: usize = 512;
const MAX_WEB_SEARCH_RESULT_TOTAL_TEXT_BYTES: usize = 4_096;
pub fn bounded_web_search_results(
results: impl IntoIterator<Item = WebSearchResult>,
) -> Vec<WebSearchResult> {
let mut bounded_results = Vec::new();
let mut total_url_bytes = 0usize;
let mut total_text_bytes = 0usize;
for result in results {
if bounded_results.len() == MAX_WEB_SEARCH_RESULTS {
break;
}
if result.url.len() > MAX_WEB_SEARCH_RESULT_URL_BYTES
|| total_url_bytes.saturating_add(result.url.len())
> MAX_WEB_SEARCH_RESULT_TOTAL_URL_BYTES
{
continue;
}
let title = bounded_web_search_result_text(
result.title,
MAX_WEB_SEARCH_RESULT_TITLE_BYTES,
MAX_WEB_SEARCH_RESULT_TOTAL_TEXT_BYTES.saturating_sub(total_text_bytes),
);
total_text_bytes += title.as_ref().map_or(0, String::len);
let snippet = bounded_web_search_result_text(
result.snippet,
MAX_WEB_SEARCH_RESULT_SNIPPET_BYTES,
MAX_WEB_SEARCH_RESULT_TOTAL_TEXT_BYTES.saturating_sub(total_text_bytes),
);
total_text_bytes += snippet.as_ref().map_or(0, String::len);
total_url_bytes += result.url.len();
bounded_results.push(WebSearchResult {
url: result.url,
title,
snippet,
});
}
bounded_results
}
fn bounded_web_search_result_text(
text: Option<String>,
max_bytes: usize,
remaining_bytes: usize,
) -> Option<String> {
let text = text?;
let text = text.trim();
let text = take_bytes_at_char_boundary(text, max_bytes.min(remaining_bytes));
(!text.is_empty()).then(|| text.to_string())
}
pub(crate) fn deserialize_web_search_results<'de, D>(
deserializer: D,
) -> Result<Option<Vec<WebSearchResult>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<Vec<WebSearchResult>>::deserialize(deserializer)?.map(bounded_web_search_results))
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: WebSearchAction,
#[serde(
default,
deserialize_with = "deserialize_web_search_results",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub results: Option<Vec<WebSearchResult>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
@@ -649,4 +736,37 @@ mod tests {
}
);
}
#[test]
fn bounds_web_search_result_text_and_total_payload() {
let results = bounded_web_search_results((0..20).map(|index| WebSearchResult {
url: format!("https://example.com/{index}"),
title: Some("t".repeat(300)),
snippet: Some("é".repeat(600)),
}));
assert_eq!(results.len(), 20);
assert!(
results
.iter()
.filter_map(|result| result.title.as_ref())
.all(|title| title.len() <= MAX_WEB_SEARCH_RESULT_TITLE_BYTES)
);
assert!(
results
.iter()
.filter_map(|result| result.snippet.as_ref())
.all(|snippet| snippet.len() <= MAX_WEB_SEARCH_RESULT_SNIPPET_BYTES)
);
assert!(
results
.iter()
.map(|result| {
result.title.as_ref().map_or(0, String::len)
+ result.snippet.as_ref().map_or(0, String::len)
})
.sum::<usize>()
<= MAX_WEB_SEARCH_RESULT_TOTAL_TEXT_BYTES
);
}
}
+1
View File
@@ -382,6 +382,7 @@ impl WebSearchItem {
call_id: self.id.clone(),
query: self.query.clone(),
action: self.action.clone(),
results: self.results.clone(),
})
}
}
+10
View File
@@ -32,6 +32,8 @@ use crate::dynamic_tools::DynamicToolCallRequest;
use crate::dynamic_tools::DynamicToolResponse;
use crate::dynamic_tools::DynamicToolSpec;
use crate::items::TurnItem;
use crate::items::WebSearchResult;
use crate::items::deserialize_web_search_results;
use crate::mcp::CallToolResult;
use crate::mcp::RequestId;
use crate::memory_citation::MemoryCitation;
@@ -2408,6 +2410,13 @@ pub struct WebSearchEndEvent {
pub call_id: String,
pub query: String,
pub action: WebSearchAction,
#[serde(
default,
deserialize_with = "deserialize_web_search_results",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub results: Option<Vec<WebSearchResult>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -5038,6 +5047,7 @@ mod tests {
query: Some("find docs".into()),
queries: None,
},
results: None,
}),
started_at_ms: 0,
};
+3 -1
View File
@@ -139,7 +139,9 @@ impl ChatWidget {
..
} => self.on_mcp_tool_call_started(item),
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),
ThreadItem::WebSearch { id, query, action } => {
ThreadItem::WebSearch {
id, query, action, ..
} => {
self.on_web_search_begin(id.clone());
self.on_web_search_end(
id,