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
13pub 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 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 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}