agtrace_providers/codex/
mapper.rs

1use agtrace_types::{ToolCallPayload, ToolKind, ToolOrigin};
2use serde_json::Value;
3
4use crate::codex::tools::{ApplyPatchArgs, PatchOperation, ReadMcpResourceArgs, ShellArgs};
5use agtrace_types::{ExecuteArgs, FileEditArgs, FileReadArgs, FileWriteArgs, SearchArgs};
6
7/// Normalize Codex-specific tool calls
8///
9/// Handles provider-specific tools like apply_patch before falling back to generic normalization.
10pub(crate) fn normalize_codex_tool_call(
11    tool_name: String,
12    arguments: serde_json::Value,
13    provider_call_id: Option<String>,
14) -> ToolCallPayload {
15    // Handle Codex-specific tools
16    match tool_name.as_str() {
17        "apply_patch" => {
18            // Try to parse as ApplyPatchArgs
19            if let Ok(patch_args) = serde_json::from_value::<ApplyPatchArgs>(arguments.clone()) {
20                // Parse the patch structure
21                match patch_args.parse() {
22                    Ok(parsed) => {
23                        // Map to FileWrite (Add) or FileEdit (Update) based on operation
24                        match parsed.operation {
25                            PatchOperation::Add => {
26                                // New file creation → FileWrite
27                                return ToolCallPayload::FileWrite {
28                                    name: tool_name,
29                                    arguments: FileWriteArgs {
30                                        file_path: parsed.file_path,
31                                        content: parsed.raw_patch,
32                                    },
33                                    provider_call_id,
34                                };
35                            }
36                            PatchOperation::Update => {
37                                // File modification → FileEdit
38                                // Note: For patches, we store the raw patch in old_string/new_string
39                                // as a placeholder. The actual diff is in the raw patch.
40                                return ToolCallPayload::FileEdit {
41                                    name: tool_name,
42                                    arguments: FileEditArgs {
43                                        file_path: parsed.file_path,
44                                        old_string: String::new(), // Placeholder: actual diff in raw patch
45                                        new_string: parsed.raw_patch.clone(),
46                                        replace_all: false,
47                                    },
48                                    provider_call_id,
49                                };
50                            }
51                        }
52                    }
53                    Err(_) => {
54                        // Parsing failed, fall back to generic
55                    }
56                }
57            }
58        }
59        "shell" => {
60            // Try to parse as ShellArgs
61            if let Ok(shell_args) = serde_json::from_value::<ShellArgs>(arguments.clone()) {
62                // Convert to standard ExecuteArgs
63                let execute_args = shell_args.to_execute_args();
64
65                // Check if this is a search or read command
66                if let Some(command) = &execute_args.command {
67                    match super::execute_intent::classify_execute_command(command) {
68                        Some(ToolKind::Search) => {
69                            // Extract search pattern if possible
70                            let pattern = super::execute_intent::extract_search_pattern(command);
71
72                            return ToolCallPayload::Search {
73                                name: tool_name,
74                                arguments: SearchArgs {
75                                    pattern,
76                                    query: None,
77                                    input: None,
78                                    path: None,
79                                    extra: serde_json::json!({"command": command}),
80                                },
81                                provider_call_id,
82                            };
83                        }
84                        Some(ToolKind::Read) => {
85                            // Extract file path if possible
86                            let file_path = super::execute_intent::extract_file_path(command);
87
88                            return ToolCallPayload::FileRead {
89                                name: tool_name,
90                                arguments: FileReadArgs {
91                                    file_path,
92                                    path: None,
93                                    pattern: None,
94                                    extra: serde_json::json!({"command": command}),
95                                },
96                                provider_call_id,
97                            };
98                        }
99                        _ => {}
100                    }
101                }
102
103                return ToolCallPayload::Execute {
104                    name: tool_name,
105                    arguments: execute_args,
106                    provider_call_id,
107                };
108            }
109        }
110        "read_mcp_resource" => {
111            // Try to parse as ReadMcpResourceArgs
112            if let Ok(mcp_args) = serde_json::from_value::<ReadMcpResourceArgs>(arguments.clone()) {
113                // Convert to standard FileReadArgs
114                let file_read_args = mcp_args.to_file_read_args();
115                return ToolCallPayload::FileRead {
116                    name: tool_name,
117                    arguments: file_read_args,
118                    provider_call_id,
119                };
120            }
121        }
122        "shell_command" => {
123            // shell_command → Execute (already uses string command format)
124            if let Ok(args) = serde_json::from_value::<ExecuteArgs>(arguments.clone()) {
125                // Check if this is a search or read command
126                if let Some(command) = &args.command {
127                    match super::execute_intent::classify_execute_command(command) {
128                        Some(ToolKind::Search) => {
129                            // Extract search pattern if possible
130                            let pattern = super::execute_intent::extract_search_pattern(command);
131
132                            return ToolCallPayload::Search {
133                                name: tool_name,
134                                arguments: SearchArgs {
135                                    pattern,
136                                    query: None,
137                                    input: None,
138                                    path: None,
139                                    extra: serde_json::json!({"command": command}),
140                                },
141                                provider_call_id,
142                            };
143                        }
144                        Some(ToolKind::Read) => {
145                            // Extract file path if possible
146                            let file_path = super::execute_intent::extract_file_path(command);
147
148                            return ToolCallPayload::FileRead {
149                                name: tool_name,
150                                arguments: FileReadArgs {
151                                    file_path,
152                                    path: None,
153                                    pattern: None,
154                                    extra: serde_json::json!({"command": command}),
155                                },
156                                provider_call_id,
157                            };
158                        }
159                        _ => {}
160                    }
161                }
162
163                return ToolCallPayload::Execute {
164                    name: tool_name,
165                    arguments: args,
166                    provider_call_id,
167                };
168            }
169        }
170        _ if tool_name.starts_with("mcp__") => {
171            // MCP tools - parse server and tool names using Codex-specific convention
172            let (server, tool) = super::tool_mapping::parse_mcp_name(&tool_name)
173                .map(|(s, t)| (Some(s), Some(t)))
174                .unwrap_or((None, None));
175
176            if let Ok(mut args) =
177                serde_json::from_value::<agtrace_types::McpArgs>(arguments.clone())
178            {
179                args.server = server;
180                args.tool = tool;
181
182                return ToolCallPayload::Mcp {
183                    name: tool_name,
184                    arguments: args,
185                    provider_call_id,
186                };
187            }
188        }
189        _ => {
190            // Unknown Codex tool, fall through to Generic
191        }
192    }
193
194    // Fallback to generic
195    ToolCallPayload::Generic {
196        name: tool_name,
197        arguments,
198        provider_call_id,
199    }
200}
201
202/// Codex tool mapper implementation
203pub struct CodexToolMapper;
204
205impl crate::traits::ToolMapper for CodexToolMapper {
206    fn classify(&self, tool_name: &str) -> (ToolOrigin, ToolKind) {
207        super::tool_mapping::classify_tool(tool_name)
208            .unwrap_or_else(|| crate::tool_analyzer::classify_common(tool_name))
209    }
210
211    fn normalize_call(&self, name: &str, args: Value, call_id: Option<String>) -> ToolCallPayload {
212        normalize_codex_tool_call(name.to_string(), args, call_id)
213    }
214
215    fn summarize(&self, kind: ToolKind, args: &Value) -> String {
216        crate::tool_analyzer::extract_common_summary(kind, args)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_normalize_apply_patch_update_file() {
226        let raw_patch = r#"*** Begin Patch
227*** Update File: test.rs
228@@
229-old line
230+new line
231@@
232*** End Patch"#;
233
234        let arguments = serde_json::json!({ "raw": raw_patch });
235        let payload = normalize_codex_tool_call(
236            "apply_patch".to_string(),
237            arguments,
238            Some("call_456".to_string()),
239        );
240
241        match payload {
242            ToolCallPayload::FileEdit {
243                name,
244                arguments,
245                provider_call_id,
246            } => {
247                assert_eq!(name, "apply_patch");
248                assert_eq!(arguments.file_path, "test.rs");
249                assert!(arguments.new_string.contains("*** Begin Patch"));
250                assert_eq!(provider_call_id, Some("call_456".to_string()));
251            }
252            _ => panic!("Expected FileEdit variant"),
253        }
254    }
255
256    #[test]
257    fn test_normalize_apply_patch_add_file() {
258        let raw_patch = r#"*** Begin Patch
259*** Add File: newfile.txt
260@@
261+new content
262@@
263*** End Patch"#;
264
265        let arguments = serde_json::json!({ "raw": raw_patch });
266        let payload = normalize_codex_tool_call(
267            "apply_patch".to_string(),
268            arguments,
269            Some("call_789".to_string()),
270        );
271
272        match payload {
273            ToolCallPayload::FileWrite {
274                name,
275                arguments,
276                provider_call_id,
277            } => {
278                assert_eq!(name, "apply_patch");
279                assert_eq!(arguments.file_path, "newfile.txt");
280                assert!(arguments.content.contains("*** Begin Patch"));
281                assert_eq!(provider_call_id, Some("call_789".to_string()));
282            }
283            _ => panic!("Expected FileWrite variant"),
284        }
285    }
286
287    #[test]
288    fn test_normalize_shell_command() {
289        // NOTE: ls is now classified as Read (file listing)
290        let arguments = serde_json::json!({
291            "command": ["ls", "-la"],
292            "cwd": "/home/user",
293            "description": "List files"
294        });
295
296        let payload =
297            normalize_codex_tool_call("shell".to_string(), arguments, Some("call_123".to_string()));
298
299        match payload {
300            ToolCallPayload::FileRead {
301                name,
302                provider_call_id,
303                ..
304            } => {
305                assert_eq!(name, "shell");
306                assert_eq!(provider_call_id, Some("call_123".to_string()));
307            }
308            _ => panic!(
309                "Expected FileRead variant for ls command, got: {:?}",
310                payload.kind()
311            ),
312        }
313    }
314
315    #[test]
316    fn test_normalize_shell_minimal() {
317        let arguments = serde_json::json!({
318            "command": ["echo", "hello"]
319        });
320
321        let payload = normalize_codex_tool_call("shell".to_string(), arguments, None);
322
323        match payload {
324            ToolCallPayload::Execute {
325                name, arguments, ..
326            } => {
327                assert_eq!(name, "shell");
328                assert_eq!(arguments.command, Some("echo hello".to_string()));
329            }
330            _ => panic!("Expected Execute variant"),
331        }
332    }
333
334    #[test]
335    fn test_normalize_shell_with_all_fields() {
336        let arguments = serde_json::json!({
337            "command": ["python", "script.py"],
338            "cwd": "/workspace",
339            "description": "Run Python script",
340            "timeout_ms": 5000
341        });
342
343        let payload = normalize_codex_tool_call("shell".to_string(), arguments, None);
344
345        match payload {
346            ToolCallPayload::Execute {
347                name, arguments, ..
348            } => {
349                assert_eq!(name, "shell");
350                assert_eq!(arguments.command, Some("python script.py".to_string()));
351            }
352            _ => panic!("Expected Execute variant"),
353        }
354    }
355
356    #[test]
357    fn test_normalize_read_mcp_resource() {
358        let arguments = serde_json::json!({
359            "server": "local",
360            "uri": "file:///path/to/file.txt"
361        });
362
363        let payload = normalize_codex_tool_call(
364            "read_mcp_resource".to_string(),
365            arguments,
366            Some("call_999".to_string()),
367        );
368
369        match payload {
370            ToolCallPayload::FileRead {
371                name,
372                arguments,
373                provider_call_id,
374            } => {
375                assert_eq!(name, "read_mcp_resource");
376                assert_eq!(
377                    arguments.file_path,
378                    Some("file:///path/to/file.txt".to_string())
379                );
380                assert_eq!(provider_call_id, Some("call_999".to_string()));
381            }
382            _ => panic!("Expected FileRead variant"),
383        }
384    }
385
386    #[test]
387    fn test_normalize_mcp_tool_parses_server_and_tool() {
388        let payload = normalize_codex_tool_call(
389            "mcp__filesystem__read".to_string(),
390            serde_json::json!({"path": "/tmp/file.txt"}),
391            Some("call_mcp".to_string()),
392        );
393
394        match payload {
395            ToolCallPayload::Mcp {
396                name,
397                arguments,
398                provider_call_id,
399            } => {
400                assert_eq!(name, "mcp__filesystem__read");
401                assert_eq!(arguments.server, Some("filesystem".to_string()));
402                assert_eq!(arguments.tool, Some("read".to_string()));
403                assert_eq!(
404                    arguments.inner.get("path").and_then(|v| v.as_str()),
405                    Some("/tmp/file.txt")
406                );
407                assert_eq!(provider_call_id, Some("call_mcp".to_string()));
408            }
409            _ => panic!("Expected Mcp variant"),
410        }
411    }
412
413    #[test]
414    fn test_normalize_mcp_tool_handles_malformed_name() {
415        let payload = normalize_codex_tool_call(
416            "mcp__noserver".to_string(),
417            serde_json::json!({"data": "test"}),
418            None,
419        );
420
421        match payload {
422            ToolCallPayload::Mcp {
423                name,
424                arguments,
425                provider_call_id,
426            } => {
427                assert_eq!(name, "mcp__noserver");
428                // Malformed MCP name should result in None for both server and tool
429                assert_eq!(arguments.server, None);
430                assert_eq!(arguments.tool, None);
431                assert_eq!(provider_call_id, None);
432            }
433            _ => panic!("Expected Mcp variant"),
434        }
435    }
436
437    #[test]
438    fn test_normalize_shell_read_command_cat() {
439        let payload = normalize_codex_tool_call(
440            "shell".to_string(),
441            serde_json::json!({
442                "command": ["cat", "file.txt"]
443            }),
444            Some("call_read".to_string()),
445        );
446
447        match payload {
448            ToolCallPayload::FileRead {
449                name,
450                arguments,
451                provider_call_id,
452            } => {
453                assert_eq!(name, "shell");
454                assert_eq!(arguments.file_path, Some("file.txt".to_string()));
455                assert_eq!(provider_call_id, Some("call_read".to_string()));
456            }
457            _ => panic!(
458                "Expected FileRead variant for cat command, got: {:?}",
459                payload.kind()
460            ),
461        }
462    }
463
464    #[test]
465    fn test_normalize_shell_read_command_sed() {
466        let payload = normalize_codex_tool_call(
467            "shell".to_string(),
468            serde_json::json!({
469                "command": ["sed", "-n", "1,200p", "packages/extension-inspector/src/App.tsx"]
470            }),
471            None,
472        );
473
474        match payload {
475            ToolCallPayload::FileRead {
476                name, arguments, ..
477            } => {
478                assert_eq!(name, "shell");
479                assert_eq!(
480                    arguments.file_path,
481                    Some("packages/extension-inspector/src/App.tsx".to_string())
482                );
483            }
484            _ => panic!(
485                "Expected FileRead variant for sed -n command, got: {:?}",
486                payload.kind()
487            ),
488        }
489    }
490
491    #[test]
492    fn test_normalize_shell_read_command_ls() {
493        let payload = normalize_codex_tool_call(
494            "shell".to_string(),
495            serde_json::json!({
496                "command": ["ls", "-la"]
497            }),
498            None,
499        );
500
501        match payload {
502            ToolCallPayload::FileRead { name, .. } => {
503                assert_eq!(name, "shell");
504                // ls doesn't have a specific file, so file_path is None
505            }
506            _ => panic!(
507                "Expected FileRead variant for ls command, got: {:?}",
508                payload.kind()
509            ),
510        }
511    }
512
513    #[test]
514    fn test_normalize_shell_write_command_mkdir() {
515        let payload = normalize_codex_tool_call(
516            "shell".to_string(),
517            serde_json::json!({
518                "command": ["mkdir", "-p", "mydir"]
519            }),
520            None,
521        );
522
523        match payload {
524            ToolCallPayload::Execute { name, .. } => {
525                assert_eq!(name, "shell");
526                // mkdir is a write command, should remain Execute
527            }
528            _ => panic!(
529                "Expected Execute variant for mkdir command, got: {:?}",
530                payload.kind()
531            ),
532        }
533    }
534
535    #[test]
536    fn test_normalize_shell_command_read() {
537        // Test with cat instead of grep (grep is now Search)
538        let payload = normalize_codex_tool_call(
539            "shell_command".to_string(),
540            serde_json::json!({
541                "command": "cat file.txt"
542            }),
543            None,
544        );
545
546        match payload {
547            ToolCallPayload::FileRead {
548                name, arguments, ..
549            } => {
550                assert_eq!(name, "shell_command");
551                assert_eq!(arguments.file_path, Some("file.txt".to_string()));
552            }
553            _ => panic!(
554                "Expected FileRead variant for cat command, got: {:?}",
555                payload.kind()
556            ),
557        }
558    }
559
560    #[test]
561    fn test_normalize_shell_bash_wrapped_read() {
562        let payload = normalize_codex_tool_call(
563            "shell".to_string(),
564            serde_json::json!({
565                "command": ["bash", "-lc", "cat", "Cargo.toml"]
566            }),
567            None,
568        );
569
570        match payload {
571            ToolCallPayload::FileRead {
572                name, arguments, ..
573            } => {
574                assert_eq!(name, "shell");
575                assert_eq!(arguments.file_path, Some("Cargo.toml".to_string()));
576            }
577            _ => panic!(
578                "Expected FileRead variant for bash-wrapped cat, got: {:?}",
579                payload.kind()
580            ),
581        }
582    }
583
584    #[test]
585    fn test_normalize_shell_search_rg() {
586        let payload = normalize_codex_tool_call(
587            "shell_command".to_string(),
588            serde_json::json!({
589                "command": "rg -n \"context window\" docs -S"
590            }),
591            None,
592        );
593
594        match payload {
595            ToolCallPayload::Search {
596                name, arguments, ..
597            } => {
598                assert_eq!(name, "shell_command");
599                assert_eq!(arguments.pattern, Some("context window".to_string()));
600            }
601            _ => panic!(
602                "Expected Search variant for rg command, got: {:?}",
603                payload.kind()
604            ),
605        }
606    }
607
608    #[test]
609    fn test_normalize_shell_read_rg_files() {
610        let payload = normalize_codex_tool_call(
611            "shell_command".to_string(),
612            serde_json::json!({
613                "command": "rg --files docs"
614            }),
615            None,
616        );
617
618        match payload {
619            ToolCallPayload::FileRead { name, .. } => {
620                assert_eq!(name, "shell_command");
621                // rg --files is file listing, not search
622            }
623            _ => panic!(
624                "Expected FileRead variant for rg --files, got: {:?}",
625                payload.kind()
626            ),
627        }
628    }
629
630    #[test]
631    fn test_normalize_shell_search_grep() {
632        let payload = normalize_codex_tool_call(
633            "shell".to_string(),
634            serde_json::json!({
635                "command": ["grep", "-n", "TODO", "src/main.rs"]
636            }),
637            None,
638        );
639
640        match payload {
641            ToolCallPayload::Search {
642                name, arguments, ..
643            } => {
644                assert_eq!(name, "shell");
645                assert_eq!(arguments.pattern, Some("TODO".to_string()));
646            }
647            _ => panic!(
648                "Expected Search variant for grep command, got: {:?}",
649                payload.kind()
650            ),
651        }
652    }
653}