M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user