1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[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
32const CLAUDE_AGENT_TYPES: &[&str] = &["claude-code", "claude"];
34
35pub 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
67pub fn normalize_generic_payload(agent: &str, event: &str, raw: &Value) -> NormalizedHookEvent {
78 let obj = raw.as_object().cloned().unwrap_or_default();
79
80 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 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 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 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 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 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 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 let subject = if user_message_text.is_some() {
161 Some("user".to_string())
163 } else {
164 obj.get("subject")
166 .and_then(|v| v.as_str().map(String::from))
167 .or_else(|| Some(agent.to_string()))
168 };
169
170 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
192pub 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
205pub fn normalize_claude_payload(agent: &str, event_name: &str, raw: &Value) -> NormalizedHookEvent {
210 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 let tool_name = get_string(raw, &["tool_name", "toolName", "name"]);
224
225 let tool_input = raw
227 .get("tool_input")
228 .or_else(|| raw.get("toolInput"))
229 .or_else(|| raw.get("input"))
230 .cloned();
231
232 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 let turn_id = get_string(raw, &["turn_id", "turnId", "message_id", "messageId"]);
247
248 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 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 let user_message_text =
271 get_string(raw, &["user_message", "userMessage"]).filter(|s| s.len() > 20);
272
273 let cwd = get_string(
275 raw,
276 &[
277 "cwd",
278 "directory",
279 "workspace",
280 "working_directory",
281 "workingDirectory",
282 ],
283 );
284
285 let observer = get_string(raw, &["observer"]).or_else(|| Some(agent.to_string()));
287
288 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 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
317pub 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
334pub 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 #[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 #[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 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 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 #[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 assert!(
774 !normalized.raw_payload.is_null(),
775 "raw_payload should not be null for {}",
776 agent
777 );
778 }
779 }
780
781 #[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}