Skip to main content

gate4agent_adapters/
hook.rs

1use gate4agent_types::{
2    AdapterId, ProviderEvent, ProviderEventValidationError, ProviderInteractionKind,
3    ProviderSessionIdentity, ProviderSessionKey, TokenUsage, PROVIDER_SESSION_LOCATOR_MAX_BYTES,
4};
5use serde_json::{Map, Value};
6use thiserror::Error;
7
8pub const HOOK_EVENT_NAME_MAX_BYTES: usize = 128;
9pub const HOOK_PAYLOAD_MAX_BYTES: usize = 1_048_576;
10pub const HOOK_TEXT_MAX_CHARS: usize = 65_536;
11pub const MIMO_CODE_HOOK_TEXT_MAX_CHARS: usize = 8_000;
12
13/// Converts a provider hook payload into transport-neutral provider events.
14///
15/// The function is deliberately stateless and performs no hook installation or
16/// network I/O. Callers own authentication, ordering, and tool-call correlation.
17pub fn normalize_hook_event(
18    adapter_id: &AdapterId,
19    event_name: &str,
20    payload: &Value,
21) -> Result<Vec<ProviderEvent>, HookAdapterError> {
22    if event_name.is_empty()
23        || event_name.len() > HOOK_EVENT_NAME_MAX_BYTES
24        || event_name.chars().any(char::is_control)
25    {
26        return Err(HookAdapterError::InvalidEventName);
27    }
28    let record = payload
29        .as_object()
30        .ok_or(HookAdapterError::PayloadMustBeObject)?;
31    if serde_json::to_vec(payload)
32        .map_err(|_| HookAdapterError::PayloadTooLarge)?
33        .len()
34        > HOOK_PAYLOAD_MAX_BYTES
35    {
36        return Err(HookAdapterError::PayloadTooLarge);
37    }
38
39    let mut events = match adapter_id.as_str() {
40        "claude-code" => normalize_claude(event_name, record),
41        "codex" => normalize_codex(event_name, record),
42        "mimo-code" => normalize_mimo_code_family(event_name, record),
43        "pi" | "omp" => normalize_pi_family(event_name, record),
44        "amp" => normalize_amp(event_name, record),
45        "grok" => normalize_grok(event_name, record),
46        "kimi" => normalize_kimi(event_name, record),
47        "cursor" => normalize_cursor(event_name, record),
48        id => Err(HookAdapterError::UnsupportedAdapter(id.to_owned())),
49    }?;
50    if !events
51        .iter()
52        .any(|event| matches!(event, ProviderEvent::SessionIdentityObserved { .. }))
53    {
54        if let Some(identity) = provider_session_identity(adapter_id, record) {
55            let position = events
56                .iter()
57                .rposition(|event| matches!(event, ProviderEvent::SessionStarted { .. }))
58                .map_or(0, |index| index + 1);
59            events.insert(
60                position,
61                ProviderEvent::SessionIdentityObserved { identity },
62            );
63        }
64    }
65    for event in &events {
66        event.validate_ingress()?;
67    }
68    Ok(events)
69}
70
71fn provider_session_identity(
72    adapter_id: &AdapterId,
73    payload: &Map<String, Value>,
74) -> Option<ProviderSessionIdentity> {
75    let (key, keys): (ProviderSessionKey, &[&str]) = match adapter_id.as_str() {
76        "claude-code" | "codex" | "kimi" | "pi" => {
77            (ProviderSessionKey::SessionId, &["session_id"])
78        }
79        "mimo-code" => (ProviderSessionKey::SessionId, &["sessionID"]),
80        "grok" => (ProviderSessionKey::SessionId, &["sessionId", "session_id"]),
81        _ => return None,
82    };
83    let id = string(payload, keys).and_then(normalize_provider_session_id)?;
84    let transcript_path = match adapter_id.as_str() {
85        "claude-code" | "codex" => string(payload, &["transcript_path", "transcriptPath"])
86            .and_then(normalize_provider_transcript_path),
87        "pi" => string(payload, &["session_file"]).and_then(normalize_provider_transcript_path),
88        _ => None,
89    };
90    if adapter_id.as_str() == "pi" && transcript_path.is_none() {
91        return None;
92    }
93    Some(ProviderSessionIdentity {
94        key,
95        id,
96        transcript_path,
97    })
98}
99
100fn normalize_mimo_code_family(
101    event_name: &str,
102    payload: &Map<String, Value>,
103) -> Result<Vec<ProviderEvent>, HookAdapterError> {
104    match event_name {
105        "SessionBusy" => Ok(vec![ProviderEvent::WorkingObserved]),
106        "SessionIdle" => Ok(vec![ProviderEvent::TurnCompleted {
107            usage: TokenUsage::default(),
108            is_cumulative: false,
109        }]),
110        "MessagePart" => {
111            let role = string(payload, &["role"]);
112            let text = string(payload, &["text"]).map(bounded_mimo_code_text);
113            match (role.as_deref(), text) {
114                (Some("user"), Some(prompt)) => Ok(vec![ProviderEvent::TurnStarted {
115                    prompt: Some(prompt),
116                }]),
117                (Some("assistant"), Some(text)) => Ok(vec![
118                    ProviderEvent::WorkingObserved,
119                    ProviderEvent::Text {
120                        text,
121                        is_delta: false,
122                    },
123                ]),
124                _ => Ok(vec![ProviderEvent::WorkingObserved]),
125            }
126        }
127        "PermissionRequest" => Ok(vec![mimo_code_interaction_event(
128            payload,
129            ProviderInteractionKind::Approval,
130        )]),
131        "AskUserQuestion" => Ok(vec![mimo_code_interaction_event(
132            payload,
133            ProviderInteractionKind::Question,
134        )]),
135        _ => Ok(Vec::new()),
136    }
137}
138
139fn mimo_code_interaction_event(
140    payload: &Map<String, Value>,
141    interaction_kind: ProviderInteractionKind,
142) -> ProviderEvent {
143    let prompt_source = first_value(payload, &["tool_input", "toolInput"])
144        .cloned()
145        .unwrap_or_else(|| Value::Object(payload.clone()));
146    let tool_name = match interaction_kind {
147        ProviderInteractionKind::Approval => {
148            string(payload, &["permission", "tool_name", "toolName"])
149                .unwrap_or_else(|| "approval".to_owned())
150        }
151        ProviderInteractionKind::Question => "AskUserQuestion".to_owned(),
152    };
153    ProviderEvent::InteractionRequested {
154        request_id: explicit_tool_id(payload).map(bounded_string),
155        interaction_kind,
156        tool_name: bounded_string(tool_name),
157        // The hook payload carries no title or structured option list the
158        // way an ACP `session/request_permission` call does -- this source
159        // has none, not a dropped one.
160        title: None,
161        prompt: input_json(Some(&prompt_source)),
162        options: Vec::new(),
163        agent_id: None,
164    }
165}
166
167fn normalize_pi_family(
168    event_name: &str,
169    payload: &Map<String, Value>,
170) -> Result<Vec<ProviderEvent>, HookAdapterError> {
171    match event_name {
172        "session_start" => Ok(Vec::new()),
173        "before_agent_start" => Ok(vec![turn_started(payload)]),
174        "agent_start" => Ok(vec![ProviderEvent::WorkingObserved]),
175        "tool_call" | "tool_execution_start" => Ok(vec![tool_started(payload)]),
176        "tool_execution_end" => Ok(vec![tool_completed(payload, false)]),
177        "message_end" => {
178            let mut events = vec![ProviderEvent::WorkingObserved];
179            if string(payload, &["role"]).as_deref() == Some("assistant") {
180                if let Some(text) = string(payload, &["text"]) {
181                    events.push(ProviderEvent::Text {
182                        text: bounded_string(text),
183                        is_delta: false,
184                    });
185                }
186            }
187            Ok(events)
188        }
189        "agent_end" => Ok(vec![ProviderEvent::TurnCompleted {
190            usage: TokenUsage::default(),
191            is_cumulative: false,
192        }]),
193        _ => Ok(Vec::new()),
194    }
195}
196
197fn normalize_amp(
198    event_name: &str,
199    payload: &Map<String, Value>,
200) -> Result<Vec<ProviderEvent>, HookAdapterError> {
201    match event_name {
202        "session.start" => Ok(Vec::new()),
203        "agent.start" => Ok(vec![ProviderEvent::TurnStarted {
204            prompt: string(payload, &["prompt", "user_prompt", "userPrompt", "message"])
205                .map(bounded_string),
206        }]),
207        "tool.call" => Ok(vec![tool_started(payload)]),
208        "tool.result" => {
209            let is_error = first_value(payload, &["error"]).is_some()
210                || string(payload, &["status"])
211                    .is_some_and(|status| matches!(status.as_str(), "error" | "failed"));
212            Ok(vec![
213                tool_completed(payload, is_error),
214                ProviderEvent::WorkingObserved,
215            ])
216        }
217        "agent.end" if string(payload, &["status"]).as_deref() == Some("cancelled") => {
218            Ok(vec![ProviderEvent::TurnInterrupted])
219        }
220        "agent.end" => Ok(vec![ProviderEvent::TurnCompleted {
221            usage: TokenUsage::default(),
222            is_cumulative: false,
223        }]),
224        _ => Ok(Vec::new()),
225    }
226}
227
228fn normalize_codex(
229    event_name: &str,
230    payload: &Map<String, Value>,
231) -> Result<Vec<ProviderEvent>, HookAdapterError> {
232    match event_name {
233        "SessionStart" => {
234            let mut events = session_started(payload, &["session_id"]);
235            events.push(turn_started(payload));
236            Ok(events)
237        }
238        "UserPromptSubmit" => Ok(vec![turn_started(payload)]),
239        "PreToolUse" => Ok(vec![tool_started(payload)]),
240        "PermissionRequest" if is_ask_user_question(tool_name(payload).as_deref()) => {
241            Ok(vec![interaction_event(
242                payload,
243                ProviderInteractionKind::Question,
244            )])
245        }
246        "PermissionRequest" => Ok(vec![interaction_event(
247            payload,
248            ProviderInteractionKind::Approval,
249        )]),
250        "PostToolUse" => Ok(vec![tool_completed(payload, false)]),
251        "Stop" => Ok(turn_completed(payload)),
252        _ => Ok(Vec::new()),
253    }
254}
255
256fn normalize_claude(
257    event_name: &str,
258    payload: &Map<String, Value>,
259) -> Result<Vec<ProviderEvent>, HookAdapterError> {
260    match event_name {
261        "SessionStart" => Ok(session_started(payload, &["session_id"])),
262        "UserPromptSubmit" => Ok(vec![turn_started(payload)]),
263        "PreToolUse" if is_ask_user_question(tool_name(payload).as_deref()) => {
264            Ok(vec![interaction_event(
265                payload,
266                ProviderInteractionKind::Question,
267            )])
268        }
269        "PreToolUse" => Ok(vec![tool_started(payload)]),
270        "PostToolUse" => Ok(vec![tool_completed(payload, false)]),
271        "PostToolUseFailure" => Ok(vec![tool_completed(payload, true)]),
272        "PermissionRequest" => Ok(vec![interaction_event(
273            payload,
274            ProviderInteractionKind::Approval,
275        )]),
276        "Stop" if bool_value(payload, &["is_interrupt"]) == Some(true) => {
277            Ok(turn_interrupted(payload))
278        }
279        "Stop" | "StopFailure" => Ok(turn_completed(payload)),
280        "SubagentStart" => Ok(subagent_started(payload)),
281        "SubagentStop" => Ok(subagent_stopped(payload)),
282        "TeammateIdle" => Ok(Vec::new()),
283        _ => Ok(Vec::new()),
284    }
285}
286
287fn normalize_grok(
288    event_name: &str,
289    payload: &Map<String, Value>,
290) -> Result<Vec<ProviderEvent>, HookAdapterError> {
291    let event = snake_event_name(event_name);
292    match event.as_str() {
293        "session_start" => Ok(session_started(payload, &["sessionId", "session_id"])),
294        "subagent_start" => Ok(subagent_started(payload)),
295        "subagent_stop" => Ok(subagent_stopped(payload)),
296        "user_prompt_submit" => Ok(vec![turn_started(payload)]),
297        "pre_tool_use" if is_ask_user_question(tool_name(payload).as_deref()) => {
298            Ok(vec![interaction_event(
299                payload,
300                ProviderInteractionKind::Question,
301            )])
302        }
303        "pre_tool_use" => Ok(vec![tool_started(payload)]),
304        "post_tool_use" => Ok(vec![tool_completed(payload, false)]),
305        "post_tool_use_failure" => Ok(vec![tool_completed(payload, true)]),
306        "stop" | "stop_failure" | "session_end" => Ok(turn_completed(payload)),
307        "notification" if is_routine_grok_permission_notification(payload) => Ok(Vec::new()),
308        "notification" if is_grok_permission_message(string(payload, &["message"]).as_deref()) => {
309            Ok(vec![interaction_event(
310                payload,
311                ProviderInteractionKind::Approval,
312            )])
313        }
314        "notification" if is_idle_message(string(payload, &["message"]).as_deref()) => {
315            Ok(vec![ProviderEvent::TurnInterrupted])
316        }
317        _ => Ok(Vec::new()),
318    }
319}
320
321fn normalize_kimi(
322    event_name: &str,
323    payload: &Map<String, Value>,
324) -> Result<Vec<ProviderEvent>, HookAdapterError> {
325    match event_name {
326        "SessionStart" => Ok(session_started(payload, &["session_id"])),
327        "SubagentStart" => Ok(subagent_started(payload)),
328        "SubagentStop" => Ok(subagent_stopped(payload)),
329        "UserPromptSubmit" => Ok(vec![turn_started(payload)]),
330        "PreToolUse" if is_ask_user_question(tool_name(payload).as_deref()) => {
331            Ok(vec![interaction_event(
332                payload,
333                ProviderInteractionKind::Question,
334            )])
335        }
336        "PreToolUse" => Ok(vec![tool_started(payload)]),
337        "PostToolUse" => Ok(vec![tool_completed(payload, false)]),
338        "PostToolUseFailure" => Ok(vec![tool_completed(payload, true)]),
339        "PermissionRequest" => Ok(vec![interaction_event(
340            payload,
341            ProviderInteractionKind::Approval,
342        )]),
343        "Stop" if bool_value(payload, &["is_interrupt"]) == Some(true) => {
344            Ok(turn_interrupted(payload))
345        }
346        "Stop" | "StopFailure" => Ok(turn_completed(payload)),
347        _ => Ok(Vec::new()),
348    }
349}
350
351fn normalize_cursor(
352    event_name: &str,
353    payload: &Map<String, Value>,
354) -> Result<Vec<ProviderEvent>, HookAdapterError> {
355    match event_name {
356        "sessionStart" => {
357            let mut events = session_started(payload, &["session_id", "sessionId"]);
358            events.push(ProviderEvent::WorkingObserved);
359            Ok(events)
360        }
361        "subagentStart" => Ok(subagent_started(payload)),
362        "subagentStop" => Ok(subagent_stopped(payload)),
363        "beforeSubmitPrompt" => Ok(vec![turn_started(payload)]),
364        "preToolUse" => Ok(vec![tool_started(payload)]),
365        "postToolUse" => Ok(vec![tool_completed(payload, false)]),
366        "postToolUseFailure" => Ok(vec![tool_completed(payload, true)]),
367        "beforeShellExecution" => Ok(vec![ProviderEvent::ToolStarted {
368            id: tool_id(payload, "Shell"),
369            name: "Shell".to_owned(),
370            input_json: input_json(payload.get("command")),
371            agent_id: provider_agent_id(payload),
372        }]),
373        "beforeMCPExecution" => {
374            let name = tool_name(payload).unwrap_or_else(|| "MCP".to_owned());
375            Ok(vec![ProviderEvent::ToolStarted {
376                id: tool_id(payload, &name),
377                name,
378                input_json: input_json(first_value(payload, &["tool_input", "command", "url"])),
379                agent_id: provider_agent_id(payload),
380            }])
381        }
382        "afterAgentResponse" => {
383            let mut events = vec![ProviderEvent::WorkingObserved];
384            if let Some(text) = string(payload, &["text"]) {
385                events.push(ProviderEvent::Text {
386                    text: bounded_string(text),
387                    is_delta: false,
388                });
389            }
390            Ok(events)
391        }
392        "stop"
393            if string(payload, &["status"])
394                .is_some_and(|status| status.as_str() != "completed") =>
395        {
396            Ok(turn_interrupted(payload))
397        }
398        "stop" | "sessionEnd" => Ok(turn_completed(payload)),
399        _ => Ok(Vec::new()),
400    }
401}
402
403fn session_started(payload: &Map<String, Value>, keys: &[&str]) -> Vec<ProviderEvent> {
404    string(payload, keys)
405        .and_then(normalize_provider_session_id)
406        .map(|session_id| {
407            vec![ProviderEvent::SessionStarted {
408                session_id,
409                model: bounded_string(string(payload, &["model", "model_id"]).unwrap_or_default()),
410                tools: Vec::new(),
411            }]
412        })
413        .unwrap_or_default()
414}
415
416fn subagent_started(payload: &Map<String, Value>) -> Vec<ProviderEvent> {
417    string(payload, &["agent_id", "agentId"])
418        .map(|agent_id| {
419            vec![ProviderEvent::SubagentStarted {
420                agent_id: bounded_string(agent_id),
421                agent_type: string(payload, &["agent_type", "agentType"]).map(bounded_string),
422                description: string(payload, &["description", "prompt"]).map(bounded_string),
423            }]
424        })
425        .unwrap_or_default()
426}
427
428fn subagent_stopped(payload: &Map<String, Value>) -> Vec<ProviderEvent> {
429    string(payload, &["agent_id", "agentId"])
430        .map(|agent_id| {
431            vec![ProviderEvent::SubagentStopped {
432                agent_id: bounded_string(agent_id),
433            }]
434        })
435        .unwrap_or_default()
436}
437
438fn tool_started(payload: &Map<String, Value>) -> ProviderEvent {
439    let name = tool_name(payload).unwrap_or_else(|| "unknown".to_owned());
440    ProviderEvent::ToolStarted {
441        id: tool_id(payload, &name),
442        name,
443        input_json: input_json(first_value(
444            payload,
445            &[
446                "toolInput",
447                "tool_input",
448                "toolArgs",
449                "args",
450                "input",
451                "arguments",
452            ],
453        )),
454        agent_id: provider_agent_id(payload),
455    }
456}
457
458fn turn_started(payload: &Map<String, Value>) -> ProviderEvent {
459    ProviderEvent::TurnStarted {
460        prompt: string(
461            payload,
462            &[
463                "prompt",
464                "user_prompt",
465                "userPrompt",
466                "user_message",
467                "initial_prompt",
468                "initialPrompt",
469            ],
470        )
471        .map(bounded_string),
472    }
473}
474
475fn tool_completed(payload: &Map<String, Value>, is_error: bool) -> ProviderEvent {
476    let name = tool_name(payload).unwrap_or_else(|| "unknown".to_owned());
477    let output = first_value(
478        payload,
479        &[
480            "toolResponse",
481            "tool_response",
482            "toolResult",
483            "tool_result",
484            "toolOutput",
485            "tool_output",
486            "output",
487            "error",
488            "message",
489        ],
490    )
491    .map(value_text)
492    .unwrap_or_default();
493    ProviderEvent::ToolCompleted {
494        id: tool_id(payload, &name),
495        output: bounded_string(output),
496        is_error,
497        duration_ms: first_value(payload, &["duration_ms", "durationMs"]).and_then(Value::as_u64),
498        agent_id: provider_agent_id(payload),
499        non_execution_kind: None,
500    }
501}
502
503fn interaction_event(
504    payload: &Map<String, Value>,
505    interaction_kind: ProviderInteractionKind,
506) -> ProviderEvent {
507    let tool_name = tool_name(payload).unwrap_or_else(|| match interaction_kind {
508        ProviderInteractionKind::Approval => "approval".to_owned(),
509        ProviderInteractionKind::Question => "question".to_owned(),
510    });
511    let prompt = if interaction_kind == ProviderInteractionKind::Question {
512        input_json(first_value(
513            payload,
514            &["toolInput", "tool_input", "toolArgs", "input", "arguments"],
515        ))
516    } else {
517        string(
518            payload,
519            &["description", "message", "body", "text", "title"],
520        )
521        .map(bounded_string)
522        .unwrap_or_else(|| {
523            input_json(first_value(
524                payload,
525                &["toolInput", "tool_input", "toolArgs", "input", "arguments"],
526            ))
527        })
528    };
529    ProviderEvent::InteractionRequested {
530        request_id: explicit_tool_id(payload).map(bounded_string),
531        interaction_kind,
532        tool_name,
533        // The hook payload carries no title or structured option list the
534        // way an ACP `session/request_permission` call does -- this source
535        // has none, not a dropped one.
536        title: None,
537        prompt,
538        options: Vec::new(),
539        agent_id: provider_agent_id(payload),
540    }
541}
542
543fn provider_agent_id(payload: &Map<String, Value>) -> Option<String> {
544    string(payload, &["agent_id", "agentId"]).map(bounded_string)
545}
546
547fn turn_completed(payload: &Map<String, Value>) -> Vec<ProviderEvent> {
548    let mut events = Vec::new();
549    if let Some(text) = string(
550        payload,
551        &[
552            "lastAssistantMessage",
553            "last_assistant_message",
554            "assistant_response",
555            "response_text",
556            "finalText",
557            "message",
558        ],
559    ) {
560        events.push(ProviderEvent::Text {
561            text: bounded_string(text),
562            is_delta: false,
563        });
564    }
565    events.push(ProviderEvent::TurnCompleted {
566        usage: TokenUsage::default(),
567        is_cumulative: false,
568    });
569    events
570}
571
572fn turn_interrupted(payload: &Map<String, Value>) -> Vec<ProviderEvent> {
573    let mut events = turn_completed(payload);
574    if let Some(last) = events.last_mut() {
575        *last = ProviderEvent::TurnInterrupted;
576    }
577    events
578}
579
580fn tool_name(payload: &Map<String, Value>) -> Option<String> {
581    string(
582        payload,
583        &["toolName", "tool_name", "tool", "name", "tool_display_name"],
584    )
585    .or_else(|| {
586        payload
587            .get("toolCall")
588            .and_then(Value::as_object)
589            .and_then(|call| string(call, &["name", "toolName", "tool_name"]))
590    })
591}
592
593fn tool_id(payload: &Map<String, Value>, fallback: &str) -> String {
594    bounded_string(explicit_tool_id(payload).unwrap_or_else(|| fallback.to_owned()))
595}
596
597fn explicit_tool_id(payload: &Map<String, Value>) -> Option<String> {
598    string(
599        payload,
600        &[
601            "tool_use_id",
602            "toolUseId",
603            "tool_call_id",
604            "toolCallId",
605            "id",
606        ],
607    )
608}
609
610fn first_value<'a>(payload: &'a Map<String, Value>, keys: &[&str]) -> Option<&'a Value> {
611    keys.iter().find_map(|key| payload.get(*key))
612}
613
614fn string(payload: &Map<String, Value>, keys: &[&str]) -> Option<String> {
615    first_value(payload, keys)
616        .and_then(Value::as_str)
617        .map(str::trim)
618        .filter(|value| !value.is_empty())
619        .map(ToOwned::to_owned)
620}
621
622fn bool_value(payload: &Map<String, Value>, keys: &[&str]) -> Option<bool> {
623    first_value(payload, keys).and_then(Value::as_bool)
624}
625
626fn input_json(value: Option<&Value>) -> String {
627    let value = value.unwrap_or(&Value::Null);
628    bounded_string(match value {
629        Value::String(text) => text.clone(),
630        _ => serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned()),
631    })
632}
633
634fn value_text(value: &Value) -> String {
635    match value {
636        Value::String(text) => text.clone(),
637        _ => serde_json::to_string(value).unwrap_or_default(),
638    }
639}
640
641fn snake_event_name(value: &str) -> String {
642    let mut normalized = String::with_capacity(value.len());
643    let mut previous_lower_or_digit = false;
644    for character in value.trim().chars() {
645        if character.is_ascii_uppercase() {
646            if previous_lower_or_digit {
647                normalized.push('_');
648            }
649            normalized.push(character.to_ascii_lowercase());
650            previous_lower_or_digit = false;
651        } else if character == '-' || character.is_ascii_whitespace() {
652            if !normalized.ends_with('_') {
653                normalized.push('_');
654            }
655            previous_lower_or_digit = false;
656        } else {
657            normalized.push(character.to_ascii_lowercase());
658            previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
659        }
660    }
661    normalized
662}
663
664fn is_ask_user_question(value: Option<&str>) -> bool {
665    normalized_tool_name(value) == "askuserquestion"
666}
667
668fn normalized_tool_name(value: Option<&str>) -> String {
669    value
670        .unwrap_or_default()
671        .chars()
672        .filter(|character| character.is_ascii_alphanumeric())
673        .flat_map(char::to_lowercase)
674        .collect()
675}
676
677fn is_grok_permission_message(message: Option<&str>) -> bool {
678    message.is_some_and(|message| {
679        let lower = message.to_ascii_lowercase();
680        [
681            "permission",
682            "approval",
683            "approve",
684            "allow",
685            "confirm",
686            "needs your",
687            "requires your",
688            "feedback",
689            "clarify",
690            "question",
691        ]
692        .iter()
693        .any(|marker| lower.contains(marker))
694    })
695}
696
697fn is_routine_grok_permission_notification(payload: &Map<String, Value>) -> bool {
698    let notification_type = string(payload, &["notificationType", "notification_type", "type"])
699        .map(|value| snake_event_name(&value));
700    let message = string(payload, &["message"]);
701    let level = string(payload, &["level"]);
702    notification_type.as_deref() == Some("permission_prompt")
703        && message.is_some_and(|message| {
704            message
705                .trim()
706                .eq_ignore_ascii_case("tool permission requested")
707        })
708        && level.is_none_or(|level| level.trim().eq_ignore_ascii_case("info"))
709}
710
711fn is_idle_message(message: Option<&str>) -> bool {
712    message.is_some_and(|message| {
713        let lower = message.to_ascii_lowercase();
714        lower.contains("waiting for your input")
715            || lower.contains("waiting for input")
716            || lower.contains("type your message")
717            || lower.contains("enter send")
718            || lower.contains("shift-tab normal")
719            || lower.contains("ask a side question")
720    })
721}
722
723fn bounded_string(value: String) -> String {
724    value.chars().take(HOOK_TEXT_MAX_CHARS).collect()
725}
726
727fn bounded_mimo_code_text(value: String) -> String {
728    value.chars().take(MIMO_CODE_HOOK_TEXT_MAX_CHARS).collect()
729}
730
731fn normalize_provider_session_id(value: String) -> Option<String> {
732    let value = value.trim();
733    if value.is_empty()
734        || value.len() > 512
735        || value.starts_with('-')
736        || value.chars().any(char::is_control)
737    {
738        return None;
739    }
740    Some(value.to_owned())
741}
742
743fn normalize_provider_transcript_path(value: String) -> Option<String> {
744    let value = value.trim();
745    if value.is_empty()
746        || value.len() > PROVIDER_SESSION_LOCATOR_MAX_BYTES
747        || value.chars().any(char::is_control)
748    {
749        return None;
750    }
751    Some(value.to_owned())
752}
753
754#[derive(Clone, Debug, Error, Eq, PartialEq)]
755pub enum HookAdapterError {
756    #[error("hook event name is empty, unsafe, or too large")]
757    InvalidEventName,
758    #[error("hook payload must be a JSON object")]
759    PayloadMustBeObject,
760    #[error("hook payload exceeds the supported bound")]
761    PayloadTooLarge,
762    #[error("hook adapter is unavailable for {0}")]
763    UnsupportedAdapter(String),
764    #[error(transparent)]
765    InvalidCanonicalEvent(#[from] ProviderEventValidationError),
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use serde_json::json;
772
773    fn id(value: &str) -> AdapterId {
774        AdapterId::new(value).unwrap()
775    }
776
777    #[test]
778    fn kimi_ask_user_pre_tool_is_a_structured_question_boundary() {
779        let events = normalize_hook_event(
780            &id("kimi"),
781            "PreToolUse",
782            &json!({
783                "tool_name": "AskUserQuestion",
784                "tool_use_id": "question-k1",
785                "tool_input": {"question": "Continue?"}
786            }),
787        )
788        .unwrap();
789        assert!(matches!(
790            events.as_slice(),
791            [ProviderEvent::InteractionRequested {
792                interaction_kind: ProviderInteractionKind::Question,
793                request_id: Some(request_id),
794                tool_name,
795                prompt,
796                ..
797            }] if request_id == "question-k1"
798                && tool_name == "AskUserQuestion"
799                && prompt.contains("Continue?")
800        ));
801    }
802
803    #[test]
804    fn grok_accepts_camel_case_hook_names() {
805        let events = normalize_hook_event(
806            &id("grok"),
807            "PostToolUseFailure",
808            &json!({"toolName": "shell", "toolResponse": "denied", "id": "t1"}),
809        )
810        .unwrap();
811        assert!(matches!(
812            events.as_slice(),
813            [ProviderEvent::ToolCompleted { id, is_error: true, .. }] if id == "t1"
814        ));
815    }
816
817    #[test]
818    fn grok_suppresses_routine_permission_chatter_but_keeps_feedback_boundaries() {
819        let routine = normalize_hook_event(
820            &id("grok"),
821            "Notification",
822            &json!({
823                "notificationType": "permission_prompt",
824                "message": "Tool permission requested",
825                "level": "info"
826            }),
827        )
828        .unwrap();
829        assert!(routine.is_empty());
830
831        let feedback = normalize_hook_event(
832            &id("grok"),
833            "Notification",
834            &json!({"message": "Grok needs your feedback to proceed"}),
835        )
836        .unwrap();
837        assert!(matches!(
838            feedback.as_slice(),
839            [ProviderEvent::InteractionRequested {
840                interaction_kind: ProviderInteractionKind::Approval,
841                prompt,
842                ..
843            }] if prompt.contains("feedback")
844        ));
845    }
846
847    #[test]
848    fn pinned_interrupt_markers_do_not_count_as_completed_turns() {
849        for (adapter, event_name, payload) in [
850            (
851                "claude-code",
852                "Stop",
853                json!({"is_interrupt": true, "last_assistant_message": "cancelled"}),
854            ),
855            (
856                "kimi",
857                "Stop",
858                json!({"is_interrupt": true, "last_assistant_message": "cancelled"}),
859            ),
860            (
861                "cursor",
862                "stop",
863                json!({"status": "aborted", "last_assistant_message": "cancelled"}),
864            ),
865        ] {
866            let events = normalize_hook_event(&id(adapter), event_name, &payload).unwrap();
867            assert!(matches!(
868                events.last(),
869                Some(ProviderEvent::TurnInterrupted)
870            ));
871            assert!(!events
872                .iter()
873                .any(|event| matches!(event, ProviderEvent::TurnCompleted { .. })));
874        }
875    }
876
877    #[test]
878    fn cursor_shell_gate_is_tool_progress_not_approval() {
879        let events = normalize_hook_event(
880            &id("cursor"),
881            "beforeShellExecution",
882            &json!({"command": "cargo check"}),
883        )
884        .unwrap();
885        assert!(matches!(
886            events.as_slice(),
887            [ProviderEvent::ToolStarted { name, .. }] if name == "Shell"
888        ));
889    }
890
891    #[test]
892    fn idle_notifications_terminate_incomplete_provider_turns() {
893        for (adapter, message) in [("grok", "Type your message")] {
894            assert_eq!(
895                normalize_hook_event(&id(adapter), "Notification", &json!({"message": message}),)
896                    .unwrap(),
897                vec![ProviderEvent::TurnInterrupted]
898            );
899        }
900    }
901
902    #[test]
903    fn malformed_or_oversized_envelopes_are_rejected() {
904        assert_eq!(
905            normalize_hook_event(&id("grok"), "Stop", &Value::Null),
906            Err(HookAdapterError::PayloadMustBeObject)
907        );
908        assert_eq!(
909            normalize_hook_event(
910                &id("grok"),
911                &"x".repeat(HOOK_EVENT_NAME_MAX_BYTES + 1),
912                &json!({})
913            ),
914            Err(HookAdapterError::InvalidEventName)
915        );
916    }
917
918    #[test]
919    fn unknown_events_are_ignored_and_normalization_is_deterministic() {
920        let payload = json!({"future_field": {"nested": true}});
921        let first = normalize_hook_event(&id("grok"), "futureEvent", &payload).unwrap();
922        let second = normalize_hook_event(&id("grok"), "futureEvent", &payload).unwrap();
923        assert!(first.is_empty());
924        assert_eq!(first, second);
925        assert!(matches!(
926            normalize_hook_event(&id("future-provider"), "Stop", &json!({})),
927            Err(HookAdapterError::UnsupportedAdapter(_))
928        ));
929    }
930
931    #[test]
932    fn claude_normalizes_subagent_lifecycle_identity() {
933        let adapter = AdapterId::new("claude-code").unwrap();
934        let started = normalize_hook_event(
935            &adapter,
936            "SubagentStart",
937            &serde_json::json!({
938                "agentId": "child-c1",
939                "agentType": "reviewer",
940                "description": "review changes"
941            }),
942        )
943        .unwrap();
944        assert!(matches!(
945            started.as_slice(),
946            [ProviderEvent::SubagentStarted {
947                agent_id,
948                agent_type: Some(agent_type),
949                description: Some(description),
950            }] if agent_id == "child-c1" && agent_type == "reviewer" && description == "review changes"
951        ));
952
953        let stopped = normalize_hook_event(
954            &adapter,
955            "SubagentStop",
956            &serde_json::json!({"agent_id": "child-c1"}),
957        )
958        .unwrap();
959        assert!(matches!(
960            stopped.as_slice(),
961            [ProviderEvent::SubagentStopped { agent_id }] if agent_id == "child-c1"
962        ));
963    }
964
965    #[test]
966    fn codex_has_independent_session_permission_and_stop_contracts() {
967        let adapter = id("codex");
968        let started = normalize_hook_event(
969            &adapter,
970            "SessionStart",
971            &json!({
972                "session_id": "codex-session-1",
973                "transcript_path": "C:/sessions/codex-rollout-1.jsonl",
974                "prompt": "resume work"
975            }),
976        )
977        .unwrap();
978        assert!(matches!(
979            started.as_slice(),
980            [
981                ProviderEvent::SessionStarted { session_id, .. },
982                ProviderEvent::SessionIdentityObserved { identity },
983                ProviderEvent::TurnStarted { prompt }
984            ] if session_id == "codex-session-1"
985                && identity.key == ProviderSessionKey::SessionId
986                && identity.id == "codex-session-1"
987                && identity.transcript_path.as_deref()
988                    == Some("C:/sessions/codex-rollout-1.jsonl")
989                && prompt.as_deref() == Some("resume work")
990        ));
991
992        let approval = normalize_hook_event(
993            &adapter,
994            "PermissionRequest",
995            &json!({
996                "tool_name": "shell",
997                "tool_use_id": "codex-tool-1",
998                "input": {"command": "git push --force"}
999            }),
1000        )
1001        .unwrap();
1002        assert!(matches!(
1003            approval.as_slice(),
1004            [ProviderEvent::InteractionRequested {
1005                request_id: Some(request_id),
1006                interaction_kind: ProviderInteractionKind::Approval,
1007                tool_name,
1008                ..
1009            }] if request_id == "codex-tool-1" && tool_name == "shell"
1010        ));
1011
1012        let question = normalize_hook_event(
1013            &adapter,
1014            "PermissionRequest",
1015            &json!({
1016                "tool_name": "AskUserQuestion",
1017                "tool_use_id": "codex-question-1",
1018                "input": {"questions": [{"question": "Choose", "options": ["a", "b"]}]}
1019            }),
1020        )
1021        .unwrap();
1022        assert!(matches!(
1023            question.as_slice(),
1024            [ProviderEvent::InteractionRequested {
1025                interaction_kind: ProviderInteractionKind::Question,
1026                prompt,
1027                ..
1028            }] if prompt.contains("Choose")
1029        ));
1030
1031        let stopped =
1032            normalize_hook_event(&adapter, "Stop", &json!({"last_assistant_message": "done"}))
1033                .unwrap();
1034        assert!(matches!(
1035            stopped.as_slice(),
1036            [ProviderEvent::Text { text, .. }, ProviderEvent::TurnCompleted { .. }]
1037                if text == "done"
1038        ));
1039    }
1040
1041    #[test]
1042    fn mimo_code_maps_status_messages_and_human_boundaries() {
1043        let adapter = id("mimo-code");
1044        let user = normalize_hook_event(
1045            &adapter,
1046            "MessagePart",
1047            &json!({
1048                "sessionID": "mimo-session-1",
1049                "messageID": "message-user-1",
1050                "role": "user",
1051                "text": "ship the fix"
1052            }),
1053        )
1054        .unwrap();
1055        assert!(matches!(
1056            user.as_slice(),
1057            [
1058                ProviderEvent::SessionIdentityObserved { identity },
1059                ProviderEvent::TurnStarted { prompt }
1060            ] if identity.key == ProviderSessionKey::SessionId
1061                && identity.id == "mimo-session-1"
1062                && prompt.as_deref() == Some("ship the fix")
1063        ));
1064
1065        let assistant = normalize_hook_event(
1066            &adapter,
1067            "MessagePart",
1068            &json!({
1069                "sessionID": "mimo-session-1",
1070                "messageID": "message-assistant-1",
1071                "role": "assistant",
1072                "text": "x".repeat(MIMO_CODE_HOOK_TEXT_MAX_CHARS + 100)
1073            }),
1074        )
1075        .unwrap();
1076        assert!(matches!(
1077            assistant.as_slice(),
1078            [
1079                ProviderEvent::SessionIdentityObserved { .. },
1080                ProviderEvent::WorkingObserved,
1081                ProviderEvent::Text { text, is_delta: false }
1082            ] if text.chars().count() == MIMO_CODE_HOOK_TEXT_MAX_CHARS
1083        ));
1084
1085        let approval = normalize_hook_event(
1086            &adapter,
1087            "PermissionRequest",
1088            &json!({
1089                "id": "permission-1",
1090                "sessionID": "mimo-session-1",
1091                "permission": "bash",
1092                "patterns": ["git push"]
1093            }),
1094        )
1095        .unwrap();
1096        assert!(matches!(
1097            approval.as_slice(),
1098            [
1099                ProviderEvent::SessionIdentityObserved { .. },
1100                ProviderEvent::InteractionRequested {
1101                    request_id: Some(request_id),
1102                    interaction_kind: ProviderInteractionKind::Approval,
1103                    tool_name,
1104                    prompt,
1105                    ..
1106                }
1107            ] if request_id == "permission-1"
1108                && tool_name == "bash"
1109                && prompt.contains("git push")
1110        ));
1111
1112        let question = normalize_hook_event(
1113            &adapter,
1114            "AskUserQuestion",
1115            &json!({
1116                "id": "question-1",
1117                "sessionID": "mimo-session-1",
1118                "questions": [{"question": "Deploy?", "options": ["yes", "no"]}]
1119            }),
1120        )
1121        .unwrap();
1122        assert!(matches!(
1123            question.as_slice(),
1124            [
1125                ProviderEvent::SessionIdentityObserved { .. },
1126                ProviderEvent::InteractionRequested {
1127                    interaction_kind: ProviderInteractionKind::Question,
1128                    tool_name,
1129                    prompt,
1130                    ..
1131                }
1132            ] if tool_name == "AskUserQuestion" && prompt.contains("Deploy?")
1133        ));
1134    }
1135
1136    #[test]
1137    fn mimo_code_keeps_an_independent_hook_contract() {
1138        let adapter = id("mimo-code");
1139        let busy = normalize_hook_event(
1140            &adapter,
1141            "SessionBusy",
1142            &json!({"sessionID": "mimo-session-1"}),
1143        )
1144        .unwrap();
1145        assert!(matches!(
1146            busy.as_slice(),
1147            [
1148                ProviderEvent::SessionIdentityObserved { identity },
1149                ProviderEvent::WorkingObserved
1150            ] if identity.key == ProviderSessionKey::SessionId
1151                && identity.id == "mimo-session-1"
1152        ));
1153
1154        let idle = normalize_hook_event(
1155            &adapter,
1156            "SessionIdle",
1157            &json!({"sessionID": "mimo-session-1"}),
1158        )
1159        .unwrap();
1160        assert!(matches!(
1161            idle.as_slice(),
1162            [
1163                ProviderEvent::SessionIdentityObserved { .. },
1164                ProviderEvent::TurnCompleted { .. }
1165            ]
1166        ));
1167    }
1168
1169    #[test]
1170    fn pi_maps_native_turn_tool_message_and_completion_events() {
1171        let adapter = id("pi");
1172        let session_start = normalize_hook_event(
1173            &adapter,
1174            "session_start",
1175            &json!({
1176                "session_id": "pi-session-1",
1177                "session_file": "/tmp/pi-session-1.jsonl"
1178            }),
1179        )
1180        .unwrap();
1181        assert_eq!(
1182            session_start,
1183            vec![ProviderEvent::SessionIdentityObserved {
1184                identity: ProviderSessionIdentity {
1185                    key: ProviderSessionKey::SessionId,
1186                    id: "pi-session-1".to_owned(),
1187                    transcript_path: Some("/tmp/pi-session-1.jsonl".to_owned()),
1188                },
1189            }]
1190        );
1191
1192        assert!(normalize_hook_event(
1193            &adapter,
1194            "session_start",
1195            &json!({"session_id": "pi-session-without-file"}),
1196        )
1197        .unwrap()
1198        .is_empty());
1199
1200        let started = normalize_hook_event(
1201            &adapter,
1202            "before_agent_start",
1203            &json!({"prompt": "resume this task"}),
1204        )
1205        .unwrap();
1206        assert!(matches!(
1207            started.as_slice(),
1208            [ProviderEvent::TurnStarted { prompt }]
1209                if prompt.as_deref() == Some("resume this task")
1210        ));
1211
1212        for event_name in ["tool_call", "tool_execution_start"] {
1213            let tool = normalize_hook_event(
1214                &adapter,
1215                event_name,
1216                &json!({"tool_name": "bash", "tool_input": {"command": "cargo test"}}),
1217            )
1218            .unwrap();
1219            assert!(matches!(
1220                tool.as_slice(),
1221                [ProviderEvent::ToolStarted { name, input_json, .. }]
1222                    if name == "bash" && input_json.contains("cargo test")
1223            ));
1224        }
1225
1226        let completed = normalize_hook_event(
1227            &adapter,
1228            "tool_execution_end",
1229            &json!({"tool_name": "bash"}),
1230        )
1231        .unwrap();
1232        assert!(matches!(
1233            completed.as_slice(),
1234            [ProviderEvent::ToolCompleted { id, is_error: false, .. }] if id == "bash"
1235        ));
1236
1237        let message = normalize_hook_event(
1238            &adapter,
1239            "message_end",
1240            &json!({"role": "assistant", "text": "Done"}),
1241        )
1242        .unwrap();
1243        assert!(matches!(
1244            message.as_slice(),
1245            [
1246                ProviderEvent::WorkingObserved,
1247                ProviderEvent::Text { text, is_delta: false }
1248            ] if text == "Done"
1249        ));
1250
1251        let ended = normalize_hook_event(&adapter, "agent_end", &json!({})).unwrap();
1252        assert!(matches!(
1253            ended.as_slice(),
1254            [ProviderEvent::TurnCompleted { .. }]
1255        ));
1256        assert!(
1257            normalize_hook_event(&adapter, "session_shutdown", &json!({}))
1258                .unwrap()
1259                .is_empty()
1260        );
1261    }
1262
1263    #[test]
1264    fn omp_keeps_an_independent_pi_family_contract_without_resume_identity() {
1265        let adapter = id("omp");
1266        let started = normalize_hook_event(
1267            &adapter,
1268            "before_agent_start",
1269            &json!({"prompt": "wire omp status", "session_id": "not-owned-by-omp"}),
1270        )
1271        .unwrap();
1272        assert!(matches!(
1273            started.as_slice(),
1274            [ProviderEvent::TurnStarted { prompt }]
1275                if prompt.as_deref() == Some("wire omp status")
1276        ));
1277
1278        let progress = normalize_hook_event(&adapter, "agent_start", &json!({})).unwrap();
1279        assert_eq!(progress, vec![ProviderEvent::WorkingObserved]);
1280    }
1281
1282    #[test]
1283    fn amp_maps_thread_lifecycle_and_cancelled_end_without_resume_claim() {
1284        let adapter = id("amp");
1285        let started = normalize_hook_event(
1286            &adapter,
1287            "agent.start",
1288            &json!({"threadId": "thread-1", "id": "agent-1", "message": "fix tests"}),
1289        )
1290        .unwrap();
1291        assert!(matches!(
1292            started.as_slice(),
1293            [ProviderEvent::TurnStarted { prompt }] if prompt.as_deref() == Some("fix tests")
1294        ));
1295
1296        let tool = normalize_hook_event(
1297            &adapter,
1298            "tool.call",
1299            &json!({
1300                "threadId": "thread-1",
1301                "toolUseId": "tool-1",
1302                "tool": "bash",
1303                "input": {"command": "cargo check"}
1304            }),
1305        )
1306        .unwrap();
1307        assert!(matches!(
1308            tool.as_slice(),
1309            [ProviderEvent::ToolStarted { id, name, input_json, .. }]
1310                if id == "tool-1" && name == "bash" && input_json.contains("cargo check")
1311        ));
1312
1313        let result = normalize_hook_event(
1314            &adapter,
1315            "tool.result",
1316            &json!({
1317                "threadId": "thread-1",
1318                "toolUseId": "tool-1",
1319                "tool": "bash",
1320                "status": "error",
1321                "error": "exit 1",
1322                "output": "failed"
1323            }),
1324        )
1325        .unwrap();
1326        assert!(matches!(
1327            result.as_slice(),
1328            [
1329                ProviderEvent::ToolCompleted { id, output, is_error: true, .. },
1330                ProviderEvent::WorkingObserved
1331            ] if id == "tool-1" && output == "failed"
1332        ));
1333
1334        let cancelled = normalize_hook_event(
1335            &adapter,
1336            "agent.end",
1337            &json!({"threadId": "thread-1", "status": "cancelled"}),
1338        )
1339        .unwrap();
1340        assert_eq!(cancelled, vec![ProviderEvent::TurnInterrupted]);
1341
1342        assert!(
1343            normalize_hook_event(&adapter, "session.start", &json!({"threadId": "thread-2"}))
1344                .unwrap()
1345                .is_empty()
1346        );
1347    }
1348
1349    #[test]
1350    fn claude_question_and_lifecycle_are_independent_canonical_events() {
1351        let adapter = id("claude-code");
1352        let session = normalize_hook_event(
1353            &adapter,
1354            "SessionStart",
1355            &json!({
1356                "session_id": "claude-session-1",
1357                "transcript_path": "C:/sessions/claude-rollout-1.jsonl"
1358            }),
1359        )
1360        .unwrap();
1361        assert!(matches!(
1362            session.as_slice(),
1363            [
1364                ProviderEvent::SessionStarted { session_id, .. },
1365                ProviderEvent::SessionIdentityObserved { identity },
1366            ] if session_id == "claude-session-1"
1367                && identity.key == ProviderSessionKey::SessionId
1368                && identity.id == "claude-session-1"
1369                && identity.transcript_path.as_deref()
1370                    == Some("C:/sessions/claude-rollout-1.jsonl")
1371        ));
1372        let question = normalize_hook_event(
1373            &adapter,
1374            "PreToolUse",
1375            &json!({
1376                "tool_name": "AskUserQuestion",
1377                "tool_use_id": "q1",
1378                "tool_input": {"question": "Continue?"},
1379                "agent_id": "a1"
1380            }),
1381        )
1382        .unwrap();
1383        assert!(matches!(
1384            question.as_slice(),
1385            [ProviderEvent::InteractionRequested {
1386                request_id: Some(request_id),
1387                interaction_kind: ProviderInteractionKind::Question,
1388                agent_id: Some(agent_id),
1389                ..
1390            }] if request_id == "q1" && agent_id == "a1"
1391        ));
1392        let started = normalize_hook_event(
1393            &adapter,
1394            "SubagentStart",
1395            &json!({"agent_id": "a1", "agent_type": "reviewer"}),
1396        )
1397        .unwrap();
1398        assert!(matches!(
1399            started.as_slice(),
1400            [ProviderEvent::SubagentStarted { agent_id, .. }] if agent_id == "a1"
1401        ));
1402    }
1403
1404    #[test]
1405    fn text_is_utf8_safe_and_bounded_by_characters() {
1406        let text = format!("привет{}", "界".repeat(HOOK_TEXT_MAX_CHARS));
1407        let events =
1408            normalize_hook_event(&id("cursor"), "afterAgentResponse", &json!({"text": text}))
1409                .unwrap();
1410        let [ProviderEvent::WorkingObserved, ProviderEvent::Text { text, .. }] = events.as_slice()
1411        else {
1412            panic!("expected working and text events");
1413        };
1414        assert_eq!(text.chars().count(), HOOK_TEXT_MAX_CHARS);
1415        assert!(text.starts_with("привет"));
1416    }
1417}