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