grok-build-upstream-mirror/crates/codegen/xai-grok-hooks/src/event.rs
grokkybara[bot] c68e39f604 Publish harness and TUI open-source
initial sync from the monorepo
2026-07-16 06:46:02 +01:00

545 lines
22 KiB
Rust

use serde::Serialize;
/// Maximum serialized size for `toolInput` or `toolResult` in bytes (128 KB).
pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024;
/// Hook event types.
///
/// Accepts both PascalCase (`"PreToolUse"`) and snake_case (`"pre_tool_use"`)
/// during deserialization for migration compatibility.
/// Serializes to snake_case for the hook envelope wire format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
// ── Session lifecycle ───────────────────────────────────────
SessionStart,
SessionEnd,
/// Fires when an agent turn ends (completed, cancelled, or error).
Stop,
/// Fires when the turn ends due to an API error. Output and exit code are ignored.
StopFailure,
// ── Tool events ─────────────────────────────────────────────
PreToolUse,
PostToolUse,
/// Fires after a tool call fails (throws an error).
PostToolUseFailure,
/// Fires when a tool call is denied by the permission system.
PermissionDenied,
// ── User / notification events ──────────────────────────────
/// Fires when the user submits a prompt.
UserPromptSubmit,
/// Fires when a notification is sent (e.g., permission prompt, idle).
Notification,
// ── Subagent events ─────────────────────────────────────────
/// Fires when a subagent is spawned.
SubagentStart,
/// Fires when a subagent completes.
SubagentStop,
/// Alias for SubagentStop (kept for backward compatibility).
SubagentEnd,
// ── Compaction events ───────────────────────────────────────
/// Fires before context compaction.
PreCompact,
/// Fires after context compaction completes.
PostCompact,
}
impl<'de> serde::Deserialize<'de> for HookEventName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
// PascalCase (native) + snake_case + camelCase (third-party compat).
// Per-operation hook names (beforeShellExecution, afterFileEdit, etc.)
// map to our generic PreToolUse/PostToolUse — the hook script receives the
// tool name in JSON input and can filter, or use the `matcher` field.
"SessionStart" | "session_start" | "sessionStart" => Ok(Self::SessionStart),
"PreToolUse"
| "pre_tool_use"
| "preToolUse"
| "beforeShellExecution"
| "beforeMCPExecution"
| "beforeReadFile" => Ok(Self::PreToolUse),
"PostToolUse"
| "post_tool_use"
| "postToolUse"
| "afterShellExecution"
| "afterMCPExecution"
| "afterFileEdit"
| "afterAgentResponse"
| "afterAgentThought" => Ok(Self::PostToolUse),
"PostToolUseFailure" | "post_tool_use_failure" | "postToolUseFailure" => {
Ok(Self::PostToolUseFailure)
}
"SessionEnd" | "session_end" | "sessionEnd" => Ok(Self::SessionEnd),
"Stop" | "stop" => Ok(Self::Stop),
"StopFailure" | "stop_failure" | "stopFailure" => Ok(Self::StopFailure),
"Notification" | "notification" => Ok(Self::Notification),
"UserPromptSubmit" | "user_prompt_submit" | "beforeSubmitPrompt" => {
Ok(Self::UserPromptSubmit)
}
"PermissionDenied" | "permission_denied" | "permissionDenied" => {
Ok(Self::PermissionDenied)
}
"SubagentStart" | "subagent_start" | "subagentStart" => Ok(Self::SubagentStart),
"SubagentStop" | "subagent_stop" | "subagentStop" => Ok(Self::SubagentStop),
"SubagentEnd" | "subagent_end" | "subagentEnd" => Ok(Self::SubagentEnd),
"PreCompact" | "pre_compact" | "preCompact" => Ok(Self::PreCompact),
"PostCompact" | "post_compact" | "postCompact" => Ok(Self::PostCompact),
other => Err(serde::de::Error::custom(format!(
"unknown hook event: '{other}'. Expected one of: \
SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, \
SessionEnd, Stop, StopFailure, Notification, UserPromptSubmit, \
PermissionDenied, SubagentStart, SubagentStop, \
PreCompact, PostCompact"
))),
}
}
}
impl std::fmt::Display for HookEventName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SessionStart => write!(f, "session_start"),
Self::PreToolUse => write!(f, "pre_tool_use"),
Self::PostToolUse => write!(f, "post_tool_use"),
Self::PostToolUseFailure => write!(f, "post_tool_use_failure"),
Self::SessionEnd => write!(f, "session_end"),
Self::Stop => write!(f, "stop"),
Self::StopFailure => write!(f, "stop_failure"),
Self::Notification => write!(f, "notification"),
Self::UserPromptSubmit => write!(f, "user_prompt_submit"),
Self::PermissionDenied => write!(f, "permission_denied"),
Self::SubagentStart => write!(f, "subagent_start"),
Self::SubagentStop | Self::SubagentEnd => write!(f, "subagent_stop"),
Self::PreCompact => write!(f, "pre_compact"),
Self::PostCompact => write!(f, "post_compact"),
}
}
}
impl HookEventName {
/// Collapse alias variants to their canonical form so a registration and the fired
/// event meet on one key regardless of which spelling each used (`SubagentEnd` is an
/// alias of `SubagentStop`).
pub fn canonical(self) -> Self {
match self {
Self::SubagentEnd => Self::SubagentStop,
other => other,
}
}
/// Returns true if this event type uses blocking (deny/allow) semantics.
pub fn is_blocking(&self) -> bool {
matches!(self, Self::PreToolUse)
}
/// Events that don't support matcher patterns (fire on every occurrence).
pub fn is_lifecycle(&self) -> bool {
matches!(
self,
Self::SessionStart | Self::SessionEnd | Self::Stop | Self::UserPromptSubmit
)
}
}
/// The normalized event envelope sent to hook commands on stdin as JSON.
///
/// Contains common metadata plus an event-specific payload.
/// All field names use camelCase for the JSON wire format.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HookEventEnvelope {
pub hook_event_name: HookEventName,
pub session_id: String,
pub cwd: String,
pub workspace_root: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_id: Option<String>,
#[serde(flatten)]
pub payload: HookPayload,
}
/// Event-specific payload variants, flattened into the envelope JSON via
/// `#[serde(untagged)]`. Grouped to match `HookEventName`.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum HookPayload {
// ── Session lifecycle ───────────────────────────────────────
SessionStart {
source: String,
#[serde(rename = "modelId", skip_serializing_if = "Option::is_none")]
model_id: Option<String>,
#[serde(rename = "agentType", skip_serializing_if = "Option::is_none")]
agent_type: Option<String>,
},
SessionEnd {
reason: String,
#[serde(rename = "turnCount", skip_serializing_if = "Option::is_none")]
turn_count: Option<u64>,
#[serde(rename = "toolCallCount", skip_serializing_if = "Option::is_none")]
tool_call_count: Option<u64>,
},
Stop {
reason: String,
},
StopFailure {
error: String,
},
// ── Tool events ─────────────────────────────────────────────
PreToolUse {
/// The tool the model invoked. For the meta-dispatch tools (`use_tool`
/// and the external MCP-call tool) this is the resolved underlying tool
/// (`server__tool`), not the dispatcher — matchers key on it directly.
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
#[serde(rename = "permissionMode", skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
/// The subagent's type when this tool runs inside one (the envelope's `sessionId`
/// gives its identity); `None` for the top-level session.
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
subagent_type: Option<String>,
},
PostToolUse {
/// Resolved underlying tool for meta-dispatch tools (see `PreToolUse`).
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolResult")]
tool_result: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
#[serde(rename = "toolResultTruncated")]
tool_result_truncated: bool,
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
#[serde(rename = "isBackgrounded")]
is_backgrounded: bool,
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
subagent_type: Option<String>,
},
PostToolUseFailure {
/// Resolved underlying tool for meta-dispatch tools (see `PreToolUse`).
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
error: String,
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
subagent_type: Option<String>,
},
PermissionDenied {
/// Resolved underlying tool for meta-dispatch tools (see `PreToolUse`).
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
tool_use_id: String,
#[serde(rename = "toolInput")]
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
},
// ── User / notification events ──────────────────────────────
/// Fires when the user submits a prompt.
UserPromptSubmit {
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
},
/// Fires on agent notifications (permission prompts, idle, etc.).
Notification {
#[serde(rename = "notificationType")]
notification_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
/// Compat: some callers use `level` instead of `notificationType`.
#[serde(skip_serializing_if = "Option::is_none")]
level: Option<String>,
},
// ── Subagent events ─────────────────────────────────────────
/// Fires when a subagent is spawned.
SubagentStart {
#[serde(rename = "subagentId")]
subagent_id: String,
#[serde(rename = "subagentType")]
subagent_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// Fires when a subagent completes.
SubagentStop {
#[serde(rename = "subagentId")]
subagent_id: String,
#[serde(rename = "subagentType")]
subagent_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
},
// ── Compaction events ───────────────────────────────────────
PreCompact {
/// "manual" or "auto".
source: String,
},
PostCompact {
/// "manual" or "auto".
source: String,
},
}
/// Truncate a JSON value if its serialized size exceeds `MAX_PAYLOAD_SIZE`.
///
/// Returns `(possibly_truncated_value, was_truncated)`.
pub fn truncate_payload(value: serde_json::Value) -> (serde_json::Value, bool) {
let serialized = serde_json::to_string(&value).unwrap_or_default();
if serialized.len() <= MAX_PAYLOAD_SIZE {
return (value, false);
}
// Cut at the largest char boundary <= MAX_PAYLOAD_SIZE so the slice never
// splits a multibyte codepoint.
let mut end = MAX_PAYLOAD_SIZE;
while !serialized.is_char_boundary(end) {
end -= 1;
}
let mut result = serialized[..end].to_string();
result.push_str(" [truncated]");
(serde_json::Value::String(result), true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_name_deser_all_variants() {
let cases: &[(&str, &str, HookEventName)] = &[
("SessionStart", "session_start", HookEventName::SessionStart),
("PreToolUse", "pre_tool_use", HookEventName::PreToolUse),
("PostToolUse", "post_tool_use", HookEventName::PostToolUse),
(
"PostToolUseFailure",
"post_tool_use_failure",
HookEventName::PostToolUseFailure,
),
("SessionEnd", "session_end", HookEventName::SessionEnd),
("Stop", "stop", HookEventName::Stop),
("StopFailure", "stop_failure", HookEventName::StopFailure),
("Notification", "notification", HookEventName::Notification),
(
"UserPromptSubmit",
"user_prompt_submit",
HookEventName::UserPromptSubmit,
),
(
"PermissionDenied",
"permission_denied",
HookEventName::PermissionDenied,
),
(
"SubagentStart",
"subagent_start",
HookEventName::SubagentStart,
),
("SubagentStop", "subagent_stop", HookEventName::SubagentStop),
("SubagentEnd", "subagent_end", HookEventName::SubagentEnd),
("PreCompact", "pre_compact", HookEventName::PreCompact),
("PostCompact", "post_compact", HookEventName::PostCompact),
];
for (pascal, snake, expected) in cases {
let from_pascal: HookEventName =
serde_json::from_str(&format!("\"{pascal}\"")).unwrap();
assert_eq!(
from_pascal, *expected,
"PascalCase deser failed for {pascal}"
);
let from_snake: HookEventName = serde_json::from_str(&format!("\"{snake}\"")).unwrap();
assert_eq!(from_snake, *expected, "snake_case deser failed for {snake}");
}
}
#[test]
fn event_name_display_all_variants() {
let cases: &[(HookEventName, &str)] = &[
(HookEventName::SessionStart, "session_start"),
(HookEventName::PreToolUse, "pre_tool_use"),
(HookEventName::PostToolUse, "post_tool_use"),
(HookEventName::PostToolUseFailure, "post_tool_use_failure"),
(HookEventName::SessionEnd, "session_end"),
(HookEventName::Stop, "stop"),
(HookEventName::StopFailure, "stop_failure"),
(HookEventName::Notification, "notification"),
(HookEventName::UserPromptSubmit, "user_prompt_submit"),
(HookEventName::PermissionDenied, "permission_denied"),
(HookEventName::SubagentStart, "subagent_start"),
(HookEventName::SubagentStop, "subagent_stop"),
(HookEventName::SubagentEnd, "subagent_stop"), // alias collapses
(HookEventName::PreCompact, "pre_compact"),
(HookEventName::PostCompact, "post_compact"),
];
for (event, expected) in cases {
assert_eq!(&event.to_string(), expected, "Display wrong for {event:?}");
}
}
#[test]
fn event_name_serde_roundtrip() {
let name = HookEventName::PreToolUse;
let json = serde_json::to_string(&name).unwrap();
assert_eq!(json, "\"pre_tool_use\"");
let parsed: HookEventName = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, name);
}
#[test]
fn event_name_unknown_rejected() {
let result = serde_json::from_str::<HookEventName>("\"UnknownEvent\"");
assert!(result.is_err());
}
#[test]
fn event_name_is_blocking() {
assert!(HookEventName::PreToolUse.is_blocking());
for event in [
HookEventName::SessionStart,
HookEventName::PostToolUse,
HookEventName::PostToolUseFailure,
HookEventName::SessionEnd,
HookEventName::Stop,
HookEventName::StopFailure,
HookEventName::Notification,
HookEventName::UserPromptSubmit,
HookEventName::PermissionDenied,
HookEventName::SubagentStart,
HookEventName::SubagentStop,
HookEventName::SubagentEnd,
HookEventName::PreCompact,
HookEventName::PostCompact,
] {
assert!(!event.is_blocking(), "{event:?} should not be blocking");
}
}
#[test]
fn event_name_is_lifecycle() {
let lifecycle = [
HookEventName::SessionStart,
HookEventName::SessionEnd,
HookEventName::Stop,
HookEventName::UserPromptSubmit,
];
for event in lifecycle {
assert!(event.is_lifecycle(), "{event:?} should be lifecycle");
}
let matchable = [
HookEventName::PreToolUse,
HookEventName::PostToolUse,
HookEventName::PostToolUseFailure,
HookEventName::PermissionDenied,
HookEventName::StopFailure,
HookEventName::Notification,
HookEventName::SubagentStart,
HookEventName::SubagentStop,
HookEventName::SubagentEnd,
HookEventName::PreCompact,
HookEventName::PostCompact,
];
for event in matchable {
assert!(
!event.is_lifecycle(),
"{event:?} should support matchers, not be lifecycle"
);
}
}
#[test]
fn truncate_small_payload() {
let value = serde_json::json!({"key": "small"});
let (result, truncated) = truncate_payload(value.clone());
assert!(!truncated);
assert_eq!(result, value);
}
#[test]
fn truncate_large_payload() {
let big_string = "x".repeat(MAX_PAYLOAD_SIZE + 1000);
let value = serde_json::Value::String(big_string);
let (result, truncated) = truncate_payload(value);
assert!(truncated);
let s = result.as_str().unwrap();
assert!(s.ends_with("[truncated]"));
// Serialized size of the result string value should be <= MAX_PAYLOAD_SIZE + overhead
assert!(s.len() < MAX_PAYLOAD_SIZE + 100);
}
#[test]
fn truncate_large_payload_cuts_on_char_boundary() {
// '€' is 3 bytes, so the MAX_PAYLOAD_SIZE-th byte lands mid-codepoint.
let value = serde_json::Value::String("".repeat(MAX_PAYLOAD_SIZE));
let (result, truncated) = truncate_payload(value);
assert!(truncated);
assert!(result.as_str().unwrap().ends_with("[truncated]"));
}
#[test]
fn envelope_serializes_camel_case() {
let envelope = HookEventEnvelope {
hook_event_name: HookEventName::SessionStart,
session_id: "test-session".into(),
cwd: "/tmp".into(),
workspace_root: "/tmp".into(),
timestamp: "2025-01-01T00:00:00Z".into(),
transcript_path: None,
client_identifier: None,
prompt_id: None,
payload: HookPayload::SessionStart {
source: "new".into(),
model_id: Some("grok-3".into()),
agent_type: None,
},
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("hookEventName"));
assert!(json.contains("sessionId"));
assert!(json.contains("workspaceRoot"));
assert!(json.contains("modelId"));
// Should NOT contain snake_case versions
assert!(!json.contains("hook_event_name"));
assert!(!json.contains("session_id"));
}
}