Skip to main content

nexus_memory_hooks/
claude_payload.rs

1//! Hook payload normalization
2//!
3//! Normalizes raw hook payloads from any agent into a stable internal event
4//! schema.  Provides a Claude-specific normalizer (handles snake_case and
5//! camelCase), a generic normalizer (common field names across agents), and a
6//! unified dispatcher that selects the right one automatically.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12/// Normalized hook event from any agent source.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct NormalizedHookEvent {
15    pub agent: String,
16    pub event_name: String,
17    pub observed_at: DateTime<Utc>,
18    pub session_id: Option<String>,
19    pub turn_id: Option<String>,
20    pub cwd: Option<String>,
21    pub tool_name: Option<String>,
22    pub tool_input: Option<Value>,
23    pub tool_response_text: Option<String>,
24    pub assistant_message_text: Option<String>,
25    pub user_message_text: Option<String>,
26    pub raw_payload: Value,
27}
28
29/// Agent type strings that should use the Claude-specific normalizer.
30const CLAUDE_AGENT_TYPES: &[&str] = &["claude-code", "claude"];
31
32/// Flatten a JSON value to a plain-text string.
33///
34/// Handles strings directly, arrays of strings (joined with newlines),
35/// and objects that contain a "text" key.  Returns `None` for null or empty.
36pub fn flatten_text_value(value: &Value) -> Option<String> {
37    match value {
38        Value::Null => None,
39        Value::String(s) if s.is_empty() => None,
40        Value::String(s) => Some(s.clone()),
41        Value::Array(items) => {
42            let flattened: Vec<String> = items.iter().filter_map(flatten_text_value).collect();
43            if flattened.is_empty() {
44                None
45            } else {
46                Some(flattened.join("\n"))
47            }
48        }
49        Value::Object(map) => {
50            if let Some(text) = map.get("text").and_then(|v| v.as_str()) {
51                if text.is_empty() {
52                    None
53                } else {
54                    Some(text.to_string())
55                }
56            } else {
57                Some(value.to_string())
58            }
59        }
60        _ => Some(value.to_string()),
61    }
62}
63
64/// Normalize a generic (non-Claude) hook payload into the stable event schema.
65///
66/// Tries common field names across different agent formats (Gemini, Qwen,
67/// Codex, Amp, Droid, etc.) for tool_name, tool_input, session_id, cwd,
68/// and message content.
69///
70/// Field coverage:
71/// - **Gemini**: `functionCall.name`, `functionCall.args`, `functionResponse.response`
72/// - **Codex/Amp**: `function.name`, `arguments`, `choices[0].message.content`
73/// - **Qwen/Droid/Hermes**: `name`, `input`, `output`, `sessionKey`
74pub fn normalize_generic_payload(agent: &str, event: &str, raw: &Value) -> NormalizedHookEvent {
75    let obj = raw.as_object().cloned().unwrap_or_default();
76
77    // tool_name: handles Gemini functionCall, Codex/Amp function, generic name
78    let tool_name = obj
79        .get("tool_name")
80        .or_else(|| obj.get("toolName"))
81        .or_else(|| obj.get("name"))
82        .or_else(|| obj.get("functionCall").and_then(|fc| fc.get("name")))
83        .or_else(|| obj.get("function").and_then(|f| f.get("name")))
84        .cloned();
85
86    // tool_input: handles Gemini functionCall.args, Codex/Amp arguments/input
87    let tool_input = obj
88        .get("tool_input")
89        .or_else(|| obj.get("toolInput"))
90        .or_else(|| obj.get("input"))
91        .or_else(|| obj.get("arguments"))
92        .or_else(|| obj.get("functionCall").and_then(|fc| fc.get("args")))
93        .or_else(|| obj.get("function").and_then(|f| f.get("arguments")))
94        .cloned();
95
96    // tool_response: handles Gemini functionResponse, generic output/result
97    let tool_response_text = obj
98        .get("tool_response_text")
99        .or_else(|| obj.get("toolResponseText"))
100        .or_else(|| obj.get("output"))
101        .or_else(|| obj.get("result"))
102        .or_else(|| {
103            obj.get("functionResponse")
104                .and_then(|fr| fr.get("response"))
105        })
106        .and_then(flatten_text_value);
107
108    // assistant_message: handles Codex/Amp choices[0].message.content, generic response
109    let assistant_message_text = obj
110        .get("assistant_message_text")
111        .or_else(|| obj.get("assistantMessageText"))
112        .or_else(|| obj.get("assistant_message"))
113        .or_else(|| obj.get("response"))
114        .or_else(|| {
115            obj.get("choices")
116                .and_then(|c| c.get(0))
117                .and_then(|c| c.get("message"))
118                .and_then(|m| m.get("content"))
119        })
120        .and_then(flatten_text_value);
121
122    let user_message_text = obj
123        .get("user_message_text")
124        .or_else(|| obj.get("userMessageText"))
125        .or_else(|| obj.get("user_message"))
126        .or_else(|| obj.get("prompt"))
127        .and_then(flatten_text_value);
128
129    // session_id: handles Gemini sessionId, Qwen sessionKey, generic variants
130    let session_id = obj
131        .get("session_id")
132        .or_else(|| obj.get("sessionId"))
133        .or_else(|| obj.get("sessionKey"))
134        .or_else(|| obj.get("thread_id"))
135        .and_then(|v| v.as_str().map(String::from));
136
137    let turn_id = obj
138        .get("turn_id")
139        .or_else(|| obj.get("turnId"))
140        .and_then(|v| v.as_str().map(String::from));
141
142    // cwd: handles generic cwd, workingDirectory, Gemini sandbox/workingDir
143    let cwd = obj
144        .get("cwd")
145        .or_else(|| obj.get("working_directory"))
146        .or_else(|| obj.get("workingDirectory"))
147        .or_else(|| obj.get("workingDir"))
148        .and_then(|v| v.as_str().map(String::from));
149
150    NormalizedHookEvent {
151        agent: agent.to_string(),
152        event_name: event.to_string(),
153        observed_at: chrono::Utc::now(),
154        session_id,
155        turn_id,
156        cwd,
157        tool_name: tool_name.and_then(|v| v.as_str().map(String::from)),
158        tool_input,
159        tool_response_text,
160        assistant_message_text,
161        user_message_text,
162        raw_payload: raw.clone(),
163    }
164}
165
166/// Normalize a hook payload into the stable event schema.
167///
168/// Automatically selects the Claude-specific or generic normalizer based
169/// on the agent type string.
170pub fn normalize_payload(agent: &str, event: &str, raw: &Value) -> NormalizedHookEvent {
171    let agent_lower = agent.to_lowercase();
172    if CLAUDE_AGENT_TYPES.contains(&agent_lower.as_str()) {
173        normalize_claude_payload(agent, event, raw)
174    } else {
175        normalize_generic_payload(agent, event, raw)
176    }
177}
178
179/// Normalize a Claude Code hook payload into a stable event schema.
180///
181/// Handles both snake_case and camelCase field names from different
182/// Claude Code versions.
183pub fn normalize_claude_payload(agent: &str, event_name: &str, raw: &Value) -> NormalizedHookEvent {
184    // Extract event_name from payload or use provided one
185    let extracted_event_name = get_string(
186        raw,
187        &[
188            "hook_event_name",
189            "hookEventName",
190            "event_name",
191            "eventName",
192        ],
193    )
194    .unwrap_or_else(|| event_name.to_string());
195
196    // Extract tool_name
197    let tool_name = get_string(raw, &["tool_name", "toolName", "name"]);
198
199    // Extract tool_input (may be an object or already parsed)
200    let tool_input = raw
201        .get("tool_input")
202        .or_else(|| raw.get("toolInput"))
203        .or_else(|| raw.get("input"))
204        .cloned();
205
206    // Extract session_id from various possible field names
207    let session_id = get_string(
208        raw,
209        &[
210            "session_id",
211            "sessionId",
212            "thread_id",
213            "threadId",
214            "conversation_id",
215            "conversationId",
216        ],
217    );
218
219    // Extract turn/message ID
220    let turn_id = get_string(raw, &["turn_id", "turnId", "message_id", "messageId"]);
221
222    // Extract tool response and stringify if needed
223    let tool_response_text = raw
224        .get("tool_response")
225        .or_else(|| raw.get("toolResponse"))
226        .and_then(|v| {
227            if v.is_string() {
228                v.as_str().map(|s| s.to_string())
229            } else {
230                Some(v.to_string())
231            }
232        });
233
234    // Extract assistant message from message.content or direct field
235    let assistant_message_text = raw
236        .get("message")
237        .and_then(|m| m.get("content"))
238        .or_else(|| raw.get("content"))
239        .or_else(|| raw.get("assistant_message"))
240        .or_else(|| raw.get("assistantMessage"))
241        .and_then(|c| flatten_message_content(Some(c)));
242
243    // Extract user message if present at top level
244    let user_message_text =
245        get_string(raw, &["user_message", "userMessage"]).filter(|s| s.len() > 20);
246
247    // Extract cwd
248    let cwd = get_string(
249        raw,
250        &[
251            "cwd",
252            "directory",
253            "workspace",
254            "working_directory",
255            "workingDirectory",
256        ],
257    );
258
259    NormalizedHookEvent {
260        agent: agent.to_string(),
261        event_name: extracted_event_name,
262        observed_at: Utc::now(),
263        session_id,
264        turn_id,
265        cwd,
266        tool_name,
267        tool_input,
268        tool_response_text,
269        assistant_message_text,
270        user_message_text,
271        raw_payload: raw.clone(),
272    }
273}
274
275/// Get a string value from a JSON object, trying multiple possible keys.
276///
277/// Returns the first non-empty string match, or None if no key matches
278/// or the matched value is not a string.
279pub fn get_string(value: &Value, keys: &[&str]) -> Option<String> {
280    for key in keys {
281        if let Some(v) = value.get(key) {
282            if let Some(s) = v.as_str() {
283                if !s.is_empty() {
284                    return Some(s.to_string());
285                }
286            }
287        }
288    }
289    None
290}
291
292/// Flatten message content from Claude's structured format into plain text.
293///
294/// Handles:
295/// - String content: returns trimmed string if non-empty
296/// - Array of content blocks: filters for text blocks and joins with newlines
297/// - Other types: returns None
298pub fn flatten_message_content(value: Option<&Value>) -> Option<String> {
299    match value {
300        Some(Value::String(s)) => {
301            let trimmed = s.trim();
302            if !trimmed.is_empty() {
303                Some(trimmed.to_string())
304            } else {
305                None
306            }
307        }
308        Some(Value::Array(arr)) => {
309            let text_blocks: Vec<&str> = arr
310                .iter()
311                .filter_map(|block| {
312                    if let Some(obj) = block.as_object() {
313                        if obj.get("type").and_then(|t| t.as_str()) == Some("text") {
314                            return obj.get("text").and_then(|t| t.as_str());
315                        }
316                    }
317                    None
318                })
319                .map(|s| s.trim())
320                .filter(|s| !s.is_empty())
321                .collect();
322
323            let joined = text_blocks.join("\n\n");
324            if !joined.is_empty() {
325                Some(joined)
326            } else {
327                None
328            }
329        }
330        _ => None,
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    #[test]
340    fn test_normalize_claude_payload_with_bash_tool() {
341        let raw = json!({
342            "hook_event_name": "post-tool-use",
343            "tool_name": "Bash",
344            "tool_input": {"command": "cargo test"},
345            "tool_response": "running 12 tests... test result: ok",
346            "session_id": "sess-123",
347            "cwd": "/project"
348        });
349
350        let normalized = normalize_claude_payload("claude-code", "post-tool-use", &raw);
351
352        assert_eq!(normalized.agent, "claude-code");
353        assert_eq!(normalized.event_name, "post-tool-use");
354        assert_eq!(normalized.tool_name, Some("Bash".to_string()));
355        assert_eq!(normalized.session_id, Some("sess-123".to_string()));
356        assert_eq!(normalized.cwd, Some("/project".to_string()));
357        assert!(normalized.tool_response_text.is_some());
358    }
359
360    #[test]
361    fn test_normalize_claude_payload_camelcase_fallback() {
362        let raw = json!({
363            "hookEventName": "postToolUse",
364            "toolName": "Read",
365            "toolInput": {"file_path": "src/main.rs"},
366            "sessionId": "sess-456"
367        });
368
369        let normalized = normalize_claude_payload("claude-code", "post-tool-use", &raw);
370
371        assert_eq!(normalized.event_name, "postToolUse");
372        assert_eq!(normalized.tool_name, Some("Read".to_string()));
373        assert_eq!(normalized.session_id, Some("sess-456".to_string()));
374    }
375
376    #[test]
377    fn test_flatten_message_content_string() {
378        let content = Value::String("  Hello world  ".to_string());
379        let result = flatten_message_content(Some(&content));
380        assert_eq!(result, Some("Hello world".to_string()));
381    }
382
383    #[test]
384    fn test_flatten_message_content_empty_string() {
385        let content = Value::String("   ".to_string());
386        let result = flatten_message_content(Some(&content));
387        assert_eq!(result, None);
388    }
389
390    #[test]
391    fn test_flatten_message_content_array() {
392        let content = json!( [
393            {"type": "text", "text": "First paragraph"},
394            {"type": "image", "source": "..."},
395            {"type": "text", "text": "Second paragraph"}
396        ]);
397
398        let result = flatten_message_content(Some(&content));
399        assert_eq!(
400            result,
401            Some("First paragraph\n\nSecond paragraph".to_string())
402        );
403    }
404
405    #[test]
406    fn test_flatten_message_content_non_text_array() {
407        let content = json!( [
408            {"type": "image", "source": "..."},
409            {"type": "tool_use", "id": "..."}
410        ]);
411
412        let result = flatten_message_content(Some(&content));
413        assert_eq!(result, None);
414    }
415
416    #[test]
417    fn test_get_string_multiple_keys() {
418        let value = json!({
419            "first": "",
420            "second": "found",
421            "third": "unused"
422        });
423
424        let result = get_string(&value, &["first", "second", "third"]);
425        assert_eq!(result, Some("found".to_string()));
426    }
427
428    #[test]
429    fn test_get_string_no_match() {
430        let value = json!({"other": "value"});
431        let result = get_string(&value, &["missing", "also_missing"]);
432        assert_eq!(result, None);
433    }
434
435    #[test]
436    fn test_normalize_minimal_payload() {
437        let raw = json!({});
438
439        let normalized = normalize_claude_payload("claude-code", "test-event", &raw);
440
441        assert_eq!(normalized.agent, "claude-code");
442        assert_eq!(normalized.event_name, "test-event");
443        assert_eq!(normalized.tool_name, None);
444        assert_eq!(normalized.session_id, None);
445    }
446
447    #[test]
448    fn test_normalize_with_message_content() {
449        let raw = json!({
450            "message": {
451                "content": [
452                    {"type": "text", "text": "Decision made"}
453                ]
454            }
455        });
456
457        let normalized = normalize_claude_payload("claude-code", "assistant-message", &raw);
458
459        assert_eq!(
460            normalized.assistant_message_text,
461            Some("Decision made".to_string())
462        );
463    }
464
465    #[test]
466    fn test_normalize_with_user_message() {
467        let raw = json!({
468            "user_message": "Please implement the feature with proper error handling"
469        });
470
471        let normalized = normalize_claude_payload("claude-code", "user-prompt-submit", &raw);
472
473        assert_eq!(
474            normalized.user_message_text,
475            Some("Please implement the feature with proper error handling".to_string())
476        );
477    }
478
479    #[test]
480    fn test_normalize_user_message_too_short() {
481        let raw = json!({
482            "userMessage": "short"
483        });
484
485        let normalized = normalize_claude_payload("claude-code", "test", &raw);
486
487        assert_eq!(normalized.user_message_text, None);
488    }
489
490    // ── Generic normalizer tests ─────────────────────────────────────
491
492    #[test]
493    fn test_normalize_generic_gemini_payload() {
494        let raw = json!({
495            "toolName": "Bash",
496            "toolInput": {"command": "npm test"},
497            "output": "12 tests passed",
498            "sessionId": "gem-sess-1",
499            "workingDirectory": "/home/user/project"
500        });
501
502        let normalized = normalize_generic_payload("gemini", "post-tool-use", &raw);
503
504        assert_eq!(normalized.agent, "gemini");
505        assert_eq!(normalized.event_name, "post-tool-use");
506        assert_eq!(normalized.tool_name, Some("Bash".to_string()));
507        assert_eq!(normalized.session_id, Some("gem-sess-1".to_string()));
508        assert_eq!(normalized.cwd, Some("/home/user/project".to_string()));
509        assert_eq!(
510            normalized.tool_response_text,
511            Some("12 tests passed".to_string())
512        );
513    }
514
515    #[test]
516    fn test_normalize_generic_qwen_payload() {
517        let raw = json!({
518            "name": "Read",
519            "input": {"file_path": "src/main.rs"},
520            "sessionKey": "qw-sess-1",
521            "cwd": "/project"
522        });
523
524        let normalized = normalize_generic_payload("qwen", "tool-use", &raw);
525
526        assert_eq!(normalized.agent, "qwen");
527        assert_eq!(normalized.tool_name, Some("Read".to_string()));
528        assert_eq!(normalized.session_id, Some("qw-sess-1".to_string()));
529        assert_eq!(normalized.cwd, Some("/project".to_string()));
530    }
531
532    #[test]
533    fn test_normalize_generic_minimal_payload() {
534        let raw = json!({});
535
536        let normalized = normalize_generic_payload("codex", "event", &raw);
537
538        assert_eq!(normalized.agent, "codex");
539        assert_eq!(normalized.event_name, "event");
540        assert_eq!(normalized.tool_name, None);
541        assert_eq!(normalized.session_id, None);
542        assert_eq!(normalized.cwd, None);
543    }
544
545    #[test]
546    fn test_normalize_generic_camelcase_fields() {
547        let raw = json!({
548            "toolName": "Write",
549            "toolInput": {"path": "foo.rs"},
550            "toolResponseText": "Written 42 bytes",
551            "assistantMessageText": "File created successfully",
552            "userMessageText": "Create a new file",
553            "turnId": "turn-1",
554            "workingDirectory": "/workspace"
555        });
556
557        let normalized = normalize_generic_payload("amp", "post-tool-use", &raw);
558
559        assert_eq!(normalized.tool_name, Some("Write".to_string()));
560        assert!(normalized.tool_input.is_some());
561        assert_eq!(
562            normalized.tool_response_text,
563            Some("Written 42 bytes".to_string())
564        );
565        assert_eq!(
566            normalized.assistant_message_text,
567            Some("File created successfully".to_string())
568        );
569        assert_eq!(
570            normalized.user_message_text,
571            Some("Create a new file".to_string())
572        );
573        assert_eq!(normalized.turn_id, Some("turn-1".to_string()));
574        assert_eq!(normalized.cwd, Some("/workspace".to_string()));
575    }
576
577    #[test]
578    fn test_flatten_text_value_null() {
579        assert_eq!(flatten_text_value(&Value::Null), None);
580    }
581
582    #[test]
583    fn test_flatten_text_value_empty_string() {
584        assert_eq!(flatten_text_value(&Value::String(String::new())), None);
585    }
586
587    #[test]
588    fn test_flatten_text_value_string() {
589        assert_eq!(
590            flatten_text_value(&Value::String("hello".to_string())),
591            Some("hello".to_string())
592        );
593    }
594
595    #[test]
596    fn test_flatten_text_value_array() {
597        let arr = json!(["line1", "line2", "line3"]);
598        assert_eq!(
599            flatten_text_value(&arr),
600            Some("line1\nline2\nline3".to_string())
601        );
602    }
603
604    #[test]
605    fn test_flatten_text_value_object_with_text() {
606        let obj = json!({"text": "content"});
607        assert_eq!(flatten_text_value(&obj), Some("content".to_string()));
608    }
609
610    // ── Unified dispatcher tests ──────────────────────────────────────
611
612    #[test]
613    fn test_normalize_payload_dispatches_claude() {
614        let raw = json!({
615            "tool_name": "Bash",
616            "session_id": "sess-1"
617        });
618
619        let normalized = normalize_payload("claude-code", "event", &raw);
620        assert_eq!(normalized.agent, "claude-code");
621        // Claude normalizer picks up session_id from "session_id"
622        assert_eq!(normalized.session_id, Some("sess-1".to_string()));
623    }
624
625    #[test]
626    fn test_normalize_payload_dispatches_generic() {
627        let raw = json!({
628            "toolName": "Read",
629            "sessionId": "sess-2"
630        });
631
632        let normalized = normalize_payload("gemini", "event", &raw);
633        assert_eq!(normalized.agent, "gemini");
634        // Generic normalizer picks up session_id from "sessionId"
635        assert_eq!(normalized.session_id, Some("sess-2".to_string()));
636    }
637
638    #[test]
639    fn test_normalize_payload_dispatches_by_alias() {
640        let raw = json!({
641            "tool_name": "Bash",
642        });
643
644        let normalized = normalize_payload("claude", "event", &raw);
645        assert_eq!(normalized.agent, "claude");
646        assert_eq!(normalized.tool_name, Some("Bash".to_string()));
647    }
648
649    // ── Cross-agent consistency tests ───────────────────────────────
650
651    #[test]
652    fn test_normalize_codex_payload() {
653        let raw = json!({
654            "toolName": "Bash",
655            "toolInput": {"command": "go test ./..."},
656            "toolResponseText": "PASS",
657            "sessionId": "cx-1",
658            "turnId": "t-1",
659            "workingDirectory": "/project"
660        });
661
662        let normalized = normalize_payload("codex", "post-tool-use", &raw);
663        assert_eq!(normalized.agent, "codex");
664        assert_eq!(normalized.tool_name, Some("Bash".to_string()));
665        assert_eq!(normalized.session_id, Some("cx-1".to_string()));
666        assert_eq!(normalized.turn_id, Some("t-1".to_string()));
667        assert_eq!(normalized.cwd, Some("/project".to_string()));
668    }
669
670    #[test]
671    fn test_normalize_amp_payload() {
672        let raw = json!({
673            "tool_name": "Edit",
674            "tool_input": {"file": "src/lib.rs"},
675            "tool_response_text": "Updated 3 lines",
676            "assistant_message_text": "Fixed the off-by-one error",
677            "session_id": "amp-sess",
678            "cwd": "/workspace"
679        });
680
681        let normalized = normalize_payload("amp", "post-tool-use", &raw);
682        assert_eq!(normalized.agent, "amp");
683        assert_eq!(normalized.tool_name, Some("Edit".to_string()));
684        assert_eq!(normalized.session_id, Some("amp-sess".to_string()));
685        assert_eq!(normalized.cwd, Some("/workspace".to_string()));
686        assert_eq!(
687            normalized.assistant_message_text,
688            Some("Fixed the off-by-one error".to_string())
689        );
690    }
691
692    #[test]
693    fn test_normalize_droid_payload_minimal() {
694        let raw = json!({
695            "name": "Read",
696            "input": {"file_path": "README.md"}
697        });
698
699        let normalized = normalize_payload("droid", "tool-use", &raw);
700        assert_eq!(normalized.agent, "droid");
701        assert_eq!(normalized.tool_name, Some("Read".to_string()));
702        assert_eq!(normalized.session_id, None);
703        assert_eq!(normalized.cwd, None);
704    }
705
706    #[test]
707    fn test_all_agents_produce_stable_schema() {
708        let agents = [
709            "claude-code",
710            "claude",
711            "gemini",
712            "qwen",
713            "codex",
714            "amp",
715            "droid",
716            "opencode",
717        ];
718        for agent in agents {
719            let raw = json!({
720                "tool_name": "Test",
721                "session_id": "s-1",
722            });
723            let normalized = normalize_payload(agent, "test-event", &raw);
724            assert_eq!(normalized.agent, agent, "agent name mismatch for {}", agent);
725            assert_eq!(
726                normalized.event_name, "test-event",
727                "event_name mismatch for {}",
728                agent
729            );
730            // All agents should produce a non-empty raw_payload
731            assert!(
732                !normalized.raw_payload.is_null(),
733                "raw_payload should not be null for {}",
734                agent
735            );
736        }
737    }
738
739    // ── Agent-specific field format tests ────────────────────────────
740
741    #[test]
742    fn test_normalize_gemini_function_call() {
743        let raw = json!({
744            "functionCall": {
745                "name": "run_command",
746                "args": {"command": "npm test"}
747            },
748            "sessionId": "gem-fc-1",
749            "workingDir": "/home/user/project"
750        });
751
752        let normalized = normalize_generic_payload("gemini", "function-call", &raw);
753
754        assert_eq!(normalized.agent, "gemini");
755        assert_eq!(normalized.tool_name, Some("run_command".to_string()));
756        assert!(normalized.tool_input.is_some());
757        assert_eq!(normalized.session_id, Some("gem-fc-1".to_string()));
758        assert_eq!(normalized.cwd, Some("/home/user/project".to_string()));
759    }
760
761    #[test]
762    fn test_normalize_gemini_function_response() {
763        let raw = json!({
764            "functionResponse": {
765                "response": "All 24 tests passed successfully"
766            },
767            "sessionId": "gem-fr-1"
768        });
769
770        let normalized = normalize_generic_payload("gemini", "function-response", &raw);
771
772        assert_eq!(
773            normalized.tool_response_text,
774            Some("All 24 tests passed successfully".to_string())
775        );
776        assert_eq!(normalized.session_id, Some("gem-fr-1".to_string()));
777    }
778
779    #[test]
780    fn test_normalize_codex_openai_function_format() {
781        let raw = json!({
782            "function": {
783                "name": "shell",
784                "arguments": "{\"command\": \"go build\"}"
785            },
786            "sessionId": "cx-fn-1",
787            "turnId": "cx-turn-1",
788            "workingDirectory": "/repo"
789        });
790
791        let normalized = normalize_generic_payload("codex", "tool-call", &raw);
792
793        assert_eq!(normalized.tool_name, Some("shell".to_string()));
794        assert!(normalized.tool_input.is_some());
795        assert_eq!(normalized.session_id, Some("cx-fn-1".to_string()));
796        assert_eq!(normalized.turn_id, Some("cx-turn-1".to_string()));
797        assert_eq!(normalized.cwd, Some("/repo".to_string()));
798    }
799
800    #[test]
801    fn test_normalize_codex_choices_message_content() {
802        let raw = json!({
803            "choices": [
804                {
805                    "message": {
806                        "content": "I've implemented the feature"
807                    }
808                }
809            ]
810        });
811
812        let normalized = normalize_generic_payload("codex", "response", &raw);
813
814        assert_eq!(
815            normalized.assistant_message_text,
816            Some("I've implemented the feature".to_string())
817        );
818    }
819
820    #[test]
821    fn test_normalize_amp_function_format() {
822        let raw = json!({
823            "function": {
824                "name": "edit_file",
825                "arguments": "{\"path\": \"src/main.rs\", \"content\": \"...\"}"
826            },
827            "sessionKey": "amp-fn-1"
828        });
829
830        let normalized = normalize_generic_payload("amp", "function-call", &raw);
831
832        assert_eq!(normalized.tool_name, Some("edit_file".to_string()));
833        assert!(normalized.tool_input.is_some());
834        assert_eq!(normalized.session_id, Some("amp-fn-1".to_string()));
835    }
836}