claude_codex/providers/codex/translate/
web_search_compat.rs1use serde_json::Value;
2
3pub struct WebSearchCompatBlock {
4 pub index: usize,
5 pub content: WebSearchCompatContent,
6}
7
8pub enum WebSearchCompatContent {
9 ServerToolUse {
10 id: String,
11 name: String,
12 input: Value,
13 },
14 WebSearchToolResult {
15 tool_use_id: String,
16 content: Vec<WebSearchResult>,
17 },
18}
19
20#[derive(Clone)]
21pub struct WebSearchResult {
22 pub title: String,
23 pub url: String,
24}
25
26pub fn server_tool_use_id_from_codex_web_search_id(id: &str) -> String {
27 let suffix: String = id
28 .chars()
29 .map(|c| {
30 if c.is_alphanumeric() || c == '_' {
31 c
32 } else {
33 '_'
34 }
35 })
36 .collect();
37 format!("srvtoolu_{suffix}")
38}
39
40fn extract_web_search_results_from_text(text: &str) -> Vec<WebSearchResult> {
41 let mut results: Vec<WebSearchResult> = Vec::new();
42 let mut seen_urls: std::collections::HashSet<String> = std::collections::HashSet::new();
43
44 let re = regex_lite::Regex::new(r#"\[([^\]\n]+)\]\((https?://[^)\s]+)\)"#).unwrap();
46 for cap in re.captures_iter(text) {
47 let title = clean_title(cap.get(1).map(|m| m.as_str()).unwrap_or(""));
48 let url = clean_url(cap.get(2).map(|m| m.as_str()).unwrap_or(""));
49 if url.is_empty() || seen_urls.contains(&url) {
50 continue;
51 }
52 seen_urls.insert(url.clone());
53 let display_title = if title.is_empty() {
54 fallback_title(&url)
55 } else {
56 title
57 };
58 results.push(WebSearchResult {
59 title: display_title,
60 url,
61 });
62 }
63
64 let re2 = regex_lite::Regex::new(r"https?://[^\s<>()|]+").unwrap();
66 for cap in re2.captures_iter(text) {
67 let raw_url = cap.get(0).map(|m| m.as_str()).unwrap_or("");
68 let url = clean_url(raw_url);
69 if url.is_empty() || seen_urls.contains(&url) {
70 continue;
71 }
72 seen_urls.insert(url.clone());
73 results.push(WebSearchResult {
74 title: fallback_title(&url),
75 url,
76 });
77 }
78
79 results
80}
81
82pub fn build_web_search_compat_blocks(
83 searches: &[super::reducer::ReducerEvent],
84 text: &str,
85) -> Vec<WebSearchCompatBlock> {
86 let results = extract_web_search_results_from_text(text);
87 let mut blocks = Vec::new();
88
89 for event in searches {
90 if let super::reducer::ReducerEvent::WebSearch {
91 index,
92 result_index,
93 id,
94 query,
95 } = event
96 {
97 blocks.push(WebSearchCompatBlock {
98 index: *index,
99 content: WebSearchCompatContent::ServerToolUse {
100 id: id.clone(),
101 name: "web_search".to_string(),
102 input: serde_json::json!({"query": query}),
103 },
104 });
105 blocks.push(WebSearchCompatBlock {
106 index: *result_index,
107 content: WebSearchCompatContent::WebSearchToolResult {
108 tool_use_id: id.clone(),
109 content: results.clone(),
110 },
111 });
112 }
113 }
114
115 blocks
116}
117
118fn clean_url(value: &str) -> String {
119 let mut out = value.trim().to_string();
120 while out.ends_with('.')
121 || out.ends_with(',')
122 || out.ends_with(';')
123 || out.ends_with(':')
124 || out.ends_with('!')
125 || out.ends_with('?')
126 {
127 out.pop();
128 }
129 out
130}
131
132fn clean_title(value: &str) -> String {
133 let no_markers = value
134 .trim_start()
135 .trim_start_matches(|c: char| c == '-' || c == '*' || c == '+' || c.is_ascii_digit())
136 .trim_start_matches(['.', ')', ' '])
137 .replace("**", "")
138 .replace('`', "")
139 .trim()
140 .to_string();
141 if let Some(pos) = no_markers.find(" - ") {
143 no_markers[..pos].trim().to_string()
144 } else if let Some(find_pos) = no_markers.find(" \u{2013} ") {
145 no_markers[..find_pos].trim().to_string()
146 } else {
147 no_markers
148 }
149}
150
151fn fallback_title(url: &str) -> String {
152 url.trim_start_matches("https://")
153 .trim_start_matches("http://")
154 .split('/')
155 .next()
156 .unwrap_or(url)
157 .to_string()
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn server_tool_use_id_format() {
166 let id = server_tool_use_id_from_codex_web_search_id("ws_123");
167 assert_eq!(id, "srvtoolu_ws_123");
168 }
169
170 #[test]
171 fn extract_results_from_text() {
172 let text = "Check [Example](https://example.com) and https://other.com/page.";
173 let results = extract_web_search_results_from_text(text);
174 assert_eq!(results.len(), 2);
175 assert!(results.iter().any(|r| r.url == "https://example.com"));
176 assert!(results.iter().any(|r| r.url == "https://other.com/page"));
177 }
178
179 #[test]
180 fn build_compat_blocks() {
181 let searches = vec![super::super::reducer::ReducerEvent::WebSearch {
182 index: 0,
183 result_index: 1,
184 id: "ws_1".to_string(),
185 query: "test".to_string(),
186 }];
187 let text = "See [Result](https://result.com)";
188 let blocks = build_web_search_compat_blocks(&searches, text);
189 assert_eq!(blocks.len(), 2);
190 match &blocks[0].content {
191 WebSearchCompatContent::ServerToolUse { name, input, .. } => {
192 assert_eq!(name, "web_search");
193 assert_eq!(input.get("query").and_then(|v| v.as_str()), Some("test"));
194 }
195 _ => panic!("expected ServerToolUse"),
196 }
197 match &blocks[1].content {
198 WebSearchCompatContent::WebSearchToolResult { content, .. } => {
199 assert_eq!(content.len(), 1);
200 assert_eq!(content[0].url, "https://result.com");
201 }
202 _ => panic!("expected WebSearchToolResult"),
203 }
204 }
205}