From d05c76f2b0ce7295421d2f2f60296c39b7d101f4 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 24 Feb 2026 14:41:34 -0500 Subject: [PATCH 01/11] Expand hook telemetry and copilot hook coverage --- agent-support/opencode/git-ai.ts | 216 ++++- src/commands/checkpoint.rs | 590 ++++++++++++ .../checkpoint_agent/agent_presets.rs | 786 +++++++++++++-- .../checkpoint_agent/agent_v1_preset.rs | 6 + .../checkpoint_agent/opencode_preset.rs | 55 ++ src/commands/git_ai_handlers.rs | 23 +- src/git/test_utils/mod.rs | 3 + src/mdm/agents/claude_code.rs | 222 +++-- src/mdm/agents/cursor.rs | 150 +-- src/mdm/agents/github_copilot.rs | 294 +++--- src/mdm/agents/opencode.rs | 9 + src/mdm/agents/vscode.rs | 12 +- src/mdm/utils.rs | 112 ++- src/metrics/attrs.rs | 94 +- src/metrics/db.rs | 137 ++- src/metrics/events.rs | 906 ++++++++++++++++++ src/metrics/mod.rs | 6 +- src/metrics/types.rs | 14 + tests/agent_presets_comprehensive.rs | 12 +- tests/claude_code.rs | 35 + tests/codex.rs | 15 + tests/cursor.rs | 37 + tests/git_repository_comprehensive.rs | 7 +- tests/github_copilot.rs | 159 ++- tests/install_hooks_comprehensive.rs | 406 ++++---- tests/opencode.rs | 38 + 26 files changed, 3689 insertions(+), 655 deletions(-) diff --git a/agent-support/opencode/git-ai.ts b/agent-support/opencode/git-ai.ts index 0ca9060d9..7355991bd 100644 --- a/agent-support/opencode/git-ai.ts +++ b/agent-support/opencode/git-ai.ts @@ -2,8 +2,8 @@ * git-ai plugin for OpenCode * * This plugin integrates git-ai with OpenCode to track AI-generated code. - * It uses the tool.execute.before and tool.execute.after events to create - * checkpoints that mark code changes as human or AI-authored. + * It uses tool, session, and message lifecycle events to emit telemetry and + * create checkpoints that mark code changes as human or AI-authored. * * Installation: * - Automatically installed by `git-ai install-hooks` @@ -25,6 +25,7 @@ const GIT_AI_BIN = "__GIT_AI_BINARY_PATH__" // Tools that modify files and should be tracked const FILE_EDIT_TOOLS = ["edit", "write"] +const MCP_TOOL_PREFIX = "mcp__" export const GitAiPlugin: Plugin = async (ctx) => { const { $ } = ctx @@ -42,9 +43,8 @@ export const GitAiPlugin: Plugin = async (ctx) => { return {} } - // Track pending edits by callID so we can reference them in the after hook - // Stores { filePath, repoDir, sessionID } for each pending edit - const pendingEdits = new Map() + // Track pending edits by callID so we can reference them in the after hook. + const pendingEdits = new Map() // Helper to find git repo root from a file path const findGitRepo = async (filePath: string): Promise => { @@ -59,78 +59,192 @@ export const GitAiPlugin: Plugin = async (ctx) => { } } + const charsCount = (value: unknown): number => { + if (typeof value !== "string") { + return 0 + } + return Array.from(value).length + } + + const getSessionId = (event: any): string | null => { + return event?.sessionID ?? event?.sessionId ?? event?.session?.id ?? event?.id ?? null + } + + const getCwd = (event: any): string => { + return event?.cwd ?? event?.workspace ?? process.cwd() + } + + const emitCheckpoint = async (payload: Record) => { + try { + const hookInput = JSON.stringify(payload) + await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet() + } catch (error) { + console.error("[git-ai] Failed to emit checkpoint payload:", String(error)) + } + } + + const emitTelemetryOnly = async ( + hookEventName: string, + event: any, + telemetryPayload: Record = {}, + ) => { + const sessionID = getSessionId(event) + if (!sessionID) { + return + } + + await emitCheckpoint({ + hook_event_name: hookEventName, + hook_source: "opencode_plugin", + session_id: sessionID, + cwd: getCwd(event), + telemetry_payload: telemetryPayload, + }) + } + return { + "session.created": async (event: any) => { + await emitTelemetryOnly("session.created", event, { + source: "opencode", + }) + }, + + "session.deleted": async (event: any) => { + const reason = event?.reason ? String(event.reason) : "completed" + await emitTelemetryOnly("session.deleted", event, { + reason, + }) + }, + + "session.idle": async (event: any) => { + await emitTelemetryOnly("session.idle", event, { + status: "idle", + }) + }, + + "message.updated": async (event: any) => { + const role = event?.role ?? event?.message?.role + const messageText = event?.text ?? event?.message?.text ?? "" + const messageID = event?.messageID ?? event?.messageId ?? event?.message?.id ?? event?.id + const telemetryPayload: Record = { + role: typeof role === "string" ? role : "unknown", + } + if (typeof messageID === "string" && messageID.length > 0) { + telemetryPayload.message_id = messageID + } + const normalizedRole = typeof role === "string" ? role.toLowerCase() : "" + const textChars = charsCount(messageText) + if ((normalizedRole === "user" || normalizedRole === "human") && textChars > 0) { + telemetryPayload.prompt_char_count = String(textChars) + } else if (normalizedRole === "assistant" && textChars > 0) { + telemetryPayload.response_char_count = String(textChars) + } + await emitTelemetryOnly("message.updated", event, telemetryPayload) + }, + + "message.part.updated": async (event: any) => { + const role = event?.role ?? event?.message?.role ?? "assistant" + const partText = event?.text ?? event?.part?.text ?? "" + const messageID = event?.messageID ?? event?.messageId ?? event?.message?.id ?? event?.id + const telemetryPayload: Record = { + role: typeof role === "string" ? role : "assistant", + } + if (typeof messageID === "string" && messageID.length > 0) { + telemetryPayload.message_id = messageID + } + const responseChars = charsCount(partText) + if (responseChars > 0) { + telemetryPayload.response_char_count = String(responseChars) + } + await emitTelemetryOnly("message.part.updated", event, telemetryPayload) + }, + "tool.execute.before": async (input, output) => { - // Only intercept file editing tools - if (!FILE_EDIT_TOOLS.includes(input.tool)) { + const sessionID = input?.sessionID + if (!sessionID) { return } - // Extract file path from tool arguments (args are in output, not input) - const filePath = output.args?.filePath as string | undefined - if (!filePath) { + const toolName = String(input?.tool ?? "unknown") + const isMcp = toolName.startsWith(MCP_TOOL_PREFIX) + const filePath = output?.args?.filePath as string | undefined + const isFileEdit = FILE_EDIT_TOOLS.includes(toolName) + + if (!isFileEdit || !filePath) { + const telemetryPayload: Record = { + tool_name: toolName, + tool_use_id: String(input?.callID ?? ""), + } + if (isMcp) { + telemetryPayload.mcp_tool_name = toolName + } + await emitTelemetryOnly("tool.execute.before", input, telemetryPayload) return } // Find the git repo for this file const repoDir = await findGitRepo(filePath) if (!repoDir) { - // File is not in a git repo, skip silently + await emitTelemetryOnly("tool.execute.before", input, { + tool_name: toolName, + tool_use_id: String(input?.callID ?? ""), + }) return } // Store filePath, repoDir, and sessionID for the after hook - pendingEdits.set(input.callID, { filePath, repoDir, sessionID: input.sessionID }) - - try { - // Create human checkpoint before AI edit - // This marks any changes since the last checkpoint as human-authored - const hookInput = JSON.stringify({ - hook_event_name: "PreToolUse", - session_id: input.sessionID, - cwd: repoDir, - tool_input: { filePath }, - }) - - await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet() - } catch (error) { - // Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent - console.error("[git-ai] Failed to create human checkpoint:", String(error)) - } + pendingEdits.set(input.callID, { filePath, repoDir, sessionID, toolName }) + + await emitCheckpoint({ + hook_event_name: "PreToolUse", + hook_source: "opencode_plugin", + session_id: sessionID, + cwd: repoDir, + tool_name: toolName, + tool_input: { filePath }, + telemetry_payload: { + tool_name: toolName, + tool_use_id: String(input?.callID ?? ""), + }, + }) }, - "tool.execute.after": async (input, _output) => { - // Only intercept file editing tools - if (!FILE_EDIT_TOOLS.includes(input.tool)) { - return - } - + "tool.execute.after": async (input, output) => { // Get the filePath and repoDir we stored in the before hook const editInfo = pendingEdits.get(input.callID) pendingEdits.delete(input.callID) if (!editInfo) { + const toolName = String(input?.tool ?? "unknown") + const telemetryPayload: Record = { + tool_name: toolName, + tool_use_id: String(input?.callID ?? ""), + } + if (toolName.startsWith(MCP_TOOL_PREFIX)) { + telemetryPayload.mcp_tool_name = toolName + } + await emitTelemetryOnly("tool.execute.after", input, telemetryPayload) return } - const { filePath, repoDir, sessionID } = editInfo - - try { - // Create AI checkpoint after edit - // This marks the changes made by this tool call as AI-authored - // Transcript is fetched from OpenCode's local storage by the preset - const hookInput = JSON.stringify({ - hook_event_name: "PostToolUse", - session_id: sessionID, - cwd: repoDir, - tool_input: { filePath }, - }) - - await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet() - } catch (error) { - // Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent - console.error("[git-ai] Failed to create AI checkpoint:", String(error)) + const { filePath, repoDir, sessionID, toolName } = editInfo + const telemetryPayload: Record = { + tool_name: toolName, + tool_use_id: String(input?.callID ?? ""), } + if (typeof output?.duration === "number") { + telemetryPayload.duration_ms = String(Math.max(0, Math.floor(output.duration))) + } + + await emitCheckpoint({ + hook_event_name: "PostToolUse", + hook_source: "opencode_plugin", + session_id: sessionID, + cwd: repoDir, + tool_name: toolName, + tool_input: { filePath }, + telemetry_payload: telemetryPayload, + }) }, } } diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index 9cf73cff9..601d18dfc 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -44,6 +44,8 @@ use crate::authorship::working_log::AgentId; /// Emit at most one `agent_usage` metric per prompt every 2.5 minutes. /// This is half of the server-side bucketing window. const AGENT_USAGE_MIN_INTERVAL_SECS: u64 = 150; +const RESPONSE_DEDUPE_TTL_SECS: u64 = 60 * 60 * 24; +const SESSION_DEDUPE_TTL_SECS: u64 = 60 * 60 * 24 * 7; /// Build EventAttributes with repo metadata. /// Reused for both AgentUsage and Checkpoint events. @@ -111,6 +113,436 @@ pub(crate) fn should_emit_agent_usage(_agent_id: &AgentId) -> bool { false } +#[cfg(not(any(test, feature = "test-support")))] +fn should_emit_telemetry_once(event_key: &str, now_ts: u64, ttl_secs: u64) -> bool { + let Ok(db) = crate::metrics::db::MetricsDatabase::global() else { + return true; + }; + let Ok(mut db_lock) = db.lock() else { + return true; + }; + db_lock + .should_emit_once(event_key, now_ts, ttl_secs) + .unwrap_or(true) +} + +#[cfg(any(test, feature = "test-support"))] +fn should_emit_telemetry_once(_event_key: &str, _now_ts: u64, _ttl_secs: u64) -> bool { + true +} + +fn payload_str<'a>(result: &'a AgentRunResult, key: &str) -> Option<&'a str> { + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get(key)) + .map(|s| s.as_str()) +} + +fn payload_u32(result: &AgentRunResult, key: &str) -> Option { + payload_str(result, key).and_then(|s| s.parse::().ok()) +} + +fn payload_u64(result: &AgentRunResult, key: &str) -> Option { + payload_str(result, key).and_then(|s| s.parse::().ok()) +} + +fn payload_is_true(result: &AgentRunResult, key: &str) -> bool { + matches!(payload_str(result, key), Some("1" | "true" | "yes")) +} + +fn is_human_like_role(role: &str) -> bool { + matches!(role.to_ascii_lowercase().as_str(), "human" | "user") +} + +fn should_emit_human_message(result: &AgentRunResult, hook: &str) -> bool { + if matches!(hook, "UserPromptSubmit" | "beforeSubmitPrompt") { + return true; + } + + if payload_u32(result, "prompt_char_count").is_none() { + return false; + } + + match payload_str(result, "role") { + Some(role) => is_human_like_role(role), + None => true, + } +} + +fn is_transcript_inferred_source(result: &AgentRunResult) -> bool { + matches!(result.hook_source.as_deref(), Some("codex_notify")) +} + +fn tool_phase_from_hook(hook: &str) -> Option<&'static str> { + match hook { + "PreToolUse" | "preToolUse" | "tool.execute.before" | "before_edit" => Some("started"), + "PostToolUse" | "postToolUse" | "tool.execute.after" | "after_edit" => Some("ended"), + "PostToolUseFailure" | "postToolUseFailure" => Some("failed"), + "PermissionRequest" => Some("permission_requested"), + _ => None, + } +} + +fn has_mcp_context(result: &AgentRunResult, tool_name: Option<&str>) -> bool { + payload_str(result, "mcp_tool_name").is_some() + || payload_str(result, "mcp_server").is_some() + || payload_str(result, "mcp_transport").is_some() + || tool_name.is_some_and(|name| name.starts_with("mcp__")) +} + +fn mcp_phase_from_hook( + hook: &str, + result: &AgentRunResult, + tool_name: Option<&str>, +) -> Option<&'static str> { + if matches!(hook, "beforeMCPExecution") { + return Some("started"); + } + if matches!(hook, "afterMCPExecution") { + return Some("ended"); + } + + if !has_mcp_context(result, tool_name) { + return None; + } + + match hook { + "PreToolUse" | "preToolUse" | "tool.execute.before" => Some("started"), + "PostToolUse" | "postToolUse" | "tool.execute.after" => Some("ended"), + "PostToolUseFailure" | "postToolUseFailure" => Some("failed"), + "PermissionRequest" => Some("permission_requested"), + _ => None, + } +} + +fn response_phases_from_hook( + hook: &str, + result: &AgentRunResult, +) -> (Option<(&'static str, u32)>, Option<(&'static str, u32)>) { + if hook == "message.part.updated" { + let is_human_role = payload_str(result, "role").is_some_and(is_human_like_role); + if is_human_role || payload_u32(result, "response_char_count").is_none() { + return (None, None); + } + return (Some(("started", 0)), None); + } + if hook == "message.updated" { + let is_human_role = payload_str(result, "role").is_some_and(is_human_like_role); + if is_human_role || payload_u32(result, "response_char_count").is_none() { + return (None, None); + } + return (None, Some(("ended", 0))); + } + if matches!( + hook, + "afterAgentResponse" | "session.idle" | "Stop" | "stop" + ) { + return (None, Some(("ended", 0))); + } + if matches!(hook, "afterAgentThought") { + return (Some(("started", 1)), None); + } + if matches!( + hook, + "PreToolUse" + | "preToolUse" + | "SubagentStart" + | "subagentStart" + | "tool.execute.before" + | "before_edit" + ) { + return (Some(("started", 1)), None); + } + if matches!( + hook, + "PostToolUse" + | "postToolUse" + | "PostToolUseFailure" + | "postToolUseFailure" + | "tool.execute.after" + | "after_edit" + ) { + return (None, Some(("ended", 1))); + } + + if result.agent_id.tool == "codex" { + return (Some(("started", 1)), Some(("ended", 0))); + } + + (None, None) +} + +fn response_dedupe_generation(result: &AgentRunResult, hook: &str, now_ts: u64) -> String { + if let Some(generation_id) = payload_str(result, "generation_id") + && !generation_id.trim().is_empty() + { + return generation_id.to_string(); + } + + for key in ["tool_use_id", "subagent_id", "message_id"] { + if let Some(value) = payload_str(result, key) + && !value.trim().is_empty() + { + return value.to_string(); + } + } + + let mut fallback_parts = Vec::new(); + for key in [ + "prompt_char_count", + "response_char_count", + "input_message_count", + "tool_name", + "status", + "reason", + ] { + if let Some(value) = payload_str(result, key) + && !value.trim().is_empty() + { + fallback_parts.push(format!("{key}={value}")); + } + } + + if !fallback_parts.is_empty() { + return generate_short_hash(&fallback_parts.join("|"), hook); + } + + // Last resort: per-second key avoids collapsing an entire session into one bucket. + format!("ts-{now_ts}") +} + +fn normalized_hook_attrs( + mut attrs: crate::metrics::EventAttributes, + result: &AgentRunResult, +) -> crate::metrics::EventAttributes { + if let Some(override_tool) = payload_str(result, "agent_tool") { + attrs = attrs.tool(override_tool); + } + + let prompt_tool = payload_str(result, "agent_tool").unwrap_or(&result.agent_id.tool); + if let Some(session_id) = payload_str(result, "session_id") + && !session_id.trim().is_empty() + { + let prompt_id = generate_short_hash(session_id, prompt_tool); + attrs = attrs.prompt_id(prompt_id).external_prompt_id(session_id); + } + + if let Some(hook_event_name) = &result.hook_event_name { + attrs = attrs.hook_event_name(hook_event_name); + } + if let Some(hook_source) = &result.hook_source { + attrs = attrs.hook_source(hook_source); + } + attrs +} + +pub(crate) fn emit_agent_hook_telemetry( + agent_run_result: Option<&AgentRunResult>, + attrs: crate::metrics::EventAttributes, +) { + let Some(result) = agent_run_result else { + return; + }; + let Some(hook_event_name) = result.hook_event_name.as_deref() else { + return; + }; + + let attrs = normalized_hook_attrs(attrs, result); + let hook = hook_event_name; + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let dedupe_generation = response_dedupe_generation(result, hook, now_ts); + + if matches!(hook, "SessionStart" | "sessionStart" | "session.created") { + let values = crate::metrics::AgentSessionValues::new() + .phase("started") + .reason(payload_str(result, "reason").unwrap_or("started")) + .source( + payload_str(result, "source") + .or(result.hook_source.as_deref()) + .unwrap_or("unknown"), + ) + .mode(payload_str(result, "mode").unwrap_or("unknown")) + .is_inferred(0); + crate::metrics::record(values, attrs.clone()); + } else if matches!(hook, "SessionEnd" | "sessionEnd" | "session.deleted") { + let mut values = crate::metrics::AgentSessionValues::new() + .phase("ended") + .reason(payload_str(result, "reason").unwrap_or("completed")) + .source( + payload_str(result, "source") + .or(result.hook_source.as_deref()) + .unwrap_or("unknown"), + ) + .mode(payload_str(result, "mode").unwrap_or("unknown")) + .is_inferred(0); + if let Some(duration_ms) = payload_u64(result, "duration_ms") { + values = values.duration_ms(duration_ms); + } + crate::metrics::record(values, attrs.clone()); + } else if result.agent_id.tool == "codex" && is_transcript_inferred_source(result) { + let dedupe_key = format!( + "session-start:{}:{}", + result.agent_id.tool, result.agent_id.id + ); + if should_emit_telemetry_once(&dedupe_key, now_ts, SESSION_DEDUPE_TTL_SECS) { + let values = crate::metrics::AgentSessionValues::new() + .phase("started") + .source("inferred") + .mode("agent") + .is_inferred(1); + crate::metrics::record(values, attrs.clone()); + } + } + + if should_emit_human_message(result, hook) { + let mut values = crate::metrics::AgentMessageValues::new().role("human"); + if let Some(char_count) = payload_u32(result, "prompt_char_count") { + values = values.prompt_char_count(char_count); + } + if let Some(attachment_count) = payload_u32(result, "attachment_count") { + values = values.attachment_count(attachment_count); + } + crate::metrics::record(values, attrs.clone()); + } + + let tool_name = payload_str(result, "tool_name"); + let phase = tool_phase_from_hook(hook); + if let Some(phase) = phase { + let mut values = crate::metrics::AgentToolCallValues::new().phase(phase); + if let Some(name) = tool_name { + values = values.tool_name(name.to_string()); + } + if let Some(tool_use_id) = payload_str(result, "tool_use_id") { + values = values.tool_use_id(tool_use_id.to_string()); + } + if let Some(duration_ms) = payload_u64(result, "duration_ms") { + values = values.duration_ms(duration_ms); + } + if let Some(failure_type) = payload_str(result, "failure_type") { + values = values.failure_type(failure_type.to_string()); + } + if payload_is_true(result, "inferred_tool") { + values = values.is_inferred(1); + } + crate::metrics::record(values, attrs.clone()); + } + + let mcp_phase = mcp_phase_from_hook(hook, result, tool_name); + if let Some(phase) = mcp_phase { + let mut values = crate::metrics::AgentMcpCallValues::new().phase(phase); + if let Some(server) = payload_str(result, "mcp_server") { + values = values.mcp_server(server.to_string()); + } + if let Some(name) = payload_str(result, "mcp_tool_name").or(tool_name) { + values = values.tool_name(name.to_string()); + } + if let Some(transport) = payload_str(result, "mcp_transport") { + values = values.transport(transport.to_string()); + } + if let Some(duration_ms) = payload_u64(result, "duration_ms") { + values = values.duration_ms(duration_ms); + } + if let Some(failure_type) = payload_str(result, "failure_type") { + values = values.failure_type(failure_type.to_string()); + } + if !matches!(hook, "beforeMCPExecution" | "afterMCPExecution") { + values = values.is_inferred(1); + } + crate::metrics::record(values, attrs.clone()); + } + + if let Some(skill_name) = payload_str(result, "skill_name") { + let mut values = crate::metrics::AgentSkillUsageValues::new().skill_name(skill_name); + if let Some(method) = payload_str(result, "skill_detection_method") { + values = values.detection_method(method); + } else { + values = values.detection_method("inferred_prompt"); + } + if !matches!( + payload_str(result, "skill_detection_method"), + Some("explicit") + ) { + values = values.is_inferred(1); + } + crate::metrics::record(values, attrs.clone()); + } + + let subagent_phase = match hook { + "SubagentStart" | "subagentStart" => Some("started"), + "SubagentStop" | "subagentStop" => Some("ended"), + _ => None, + }; + if let Some(phase) = subagent_phase { + let mut values = crate::metrics::AgentSubagentValues::new().phase(phase); + if let Some(subagent_id) = payload_str(result, "subagent_id") { + values = values.subagent_id(subagent_id); + } + if let Some(subagent_type) = payload_str(result, "subagent_type") { + values = values.subagent_type(subagent_type); + } + if let Some(status) = payload_str(result, "status") { + values = values.status(status); + } + if let Some(duration_ms) = payload_u64(result, "duration_ms") { + values = values.duration_ms(duration_ms); + } + if let Some(result_char_count) = payload_u32(result, "result_char_count") { + values = values.result_char_count(result_char_count); + } + crate::metrics::record(values, attrs.clone()); + } + + let (response_start_phase, response_end_phase) = response_phases_from_hook(hook, result); + + if let Some((phase, inferred)) = response_start_phase { + let dedupe_key = format!( + "response-start:{}:{}:{}", + result.agent_id.tool, result.agent_id.id, &dedupe_generation + ); + let should_dedupe = inferred == 1 && is_transcript_inferred_source(result); + if !should_dedupe + || should_emit_telemetry_once(&dedupe_key, now_ts, RESPONSE_DEDUPE_TTL_SECS) + { + let mut values = crate::metrics::AgentResponseValues::new() + .phase(phase) + .is_inferred(inferred); + if let Some(reason) = payload_str(result, "reason") { + values = values.reason(reason); + } + crate::metrics::record(values, attrs.clone()); + } + } + + if let Some((phase, inferred)) = response_end_phase { + let dedupe_key = format!( + "response-end:{}:{}:{}", + result.agent_id.tool, result.agent_id.id, &dedupe_generation + ); + let should_dedupe = inferred == 1 && is_transcript_inferred_source(result); + if !should_dedupe + || should_emit_telemetry_once(&dedupe_key, now_ts, RESPONSE_DEDUPE_TTL_SECS) + { + let mut values = crate::metrics::AgentResponseValues::new() + .phase(phase) + .is_inferred(inferred); + if let Some(status) = payload_str(result, "status") { + values = values.status(status); + } + if let Some(reason) = payload_str(result, "reason") { + values = values.reason(reason); + } + if let Some(char_count) = payload_u32(result, "response_char_count") { + values = values.response_char_count(char_count); + } + crate::metrics::record(values, attrs); + } + } +} + #[allow(clippy::too_many_arguments)] pub fn run( repo: &Repository, @@ -156,6 +588,19 @@ pub fn run( storage_start.elapsed() )); + if agent_run_result + .as_ref() + .is_some_and(|r| payload_is_true(r, "telemetry_only")) + { + let telemetry_attrs = build_checkpoint_attrs( + repo, + &base_commit, + agent_run_result.as_ref().map(|r| &r.agent_id), + ); + emit_agent_hook_telemetry(agent_run_result.as_ref(), telemetry_attrs); + return Ok((0, 0, 0)); + } + // Early exit for human only if is_pre_commit { let has_no_ai_edits = working_log @@ -476,6 +921,13 @@ pub fn run( } } + let telemetry_attrs = build_checkpoint_attrs( + repo, + &base_commit, + agent_run_result.as_ref().map(|r| &r.agent_id), + ); + emit_agent_hook_telemetry(agent_run_result.as_ref(), telemetry_attrs); + let agent_tool = if kind != CheckpointKind::Human && let Some(agent_run_result) = &agent_run_result { @@ -1668,6 +2120,9 @@ mod tests { ]), will_edit_filepaths: None, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }; // Run checkpoint - should not crash even with paths outside repo @@ -2097,4 +2552,139 @@ mod tests { "Whitespace deletions ignored" ); } + + fn test_agent_run_result_for_telemetry(payload: &[(&str, &str)]) -> AgentRunResult { + let telemetry_payload = payload + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect::>(); + + AgentRunResult { + agent_id: AgentId { + tool: "human".to_string(), + id: "human".to_string(), + model: "human".to_string(), + }, + agent_metadata: None, + checkpoint_kind: CheckpointKind::Human, + transcript: None, + repo_working_dir: None, + edited_filepaths: None, + will_edit_filepaths: None, + dirty_files: None, + hook_event_name: None, + hook_source: Some("test".to_string()), + telemetry_payload: Some(telemetry_payload), + } + } + + #[test] + fn test_tool_phase_from_hook_supports_legacy_copilot_events() { + assert_eq!(tool_phase_from_hook("before_edit"), Some("started")); + assert_eq!(tool_phase_from_hook("after_edit"), Some("ended")); + } + + #[test] + fn test_mcp_phase_from_hook_supports_opencode_tool_execute_events() { + let result = test_agent_run_result_for_telemetry(&[ + ("tool_name", "mcp__list_files"), + ("mcp_tool_name", "mcp__list_files"), + ]); + + assert_eq!( + mcp_phase_from_hook("tool.execute.before", &result, Some("mcp__list_files")), + Some("started") + ); + assert_eq!( + mcp_phase_from_hook("tool.execute.after", &result, Some("mcp__list_files")), + Some("ended") + ); + } + + #[test] + fn test_response_phases_from_hook_supports_opencode_message_hooks() { + let assistant = test_agent_run_result_for_telemetry(&[ + ("role", "assistant"), + ("response_char_count", "12"), + ]); + + assert_eq!( + response_phases_from_hook("message.part.updated", &assistant), + (Some(("started", 0)), None) + ); + assert_eq!( + response_phases_from_hook("message.updated", &assistant), + (None, Some(("ended", 0))) + ); + + let human = test_agent_run_result_for_telemetry(&[ + ("role", "human"), + ("response_char_count", "12"), + ]); + assert_eq!( + response_phases_from_hook("message.updated", &human), + (None, None) + ); + } + + #[test] + fn test_response_dedupe_generation_avoids_constant_na_when_generation_missing() { + let with_tool_use_id = test_agent_run_result_for_telemetry(&[ + ("tool_use_id", "call-123"), + ("response_char_count", "8"), + ]); + assert_eq!( + response_dedupe_generation(&with_tool_use_id, "agent-turn-complete", 123), + "call-123" + ); + + let fallback = test_agent_run_result_for_telemetry(&[ + ("prompt_char_count", "10"), + ("response_char_count", "8"), + ]); + let key = response_dedupe_generation(&fallback, "agent-turn-complete", 123); + assert_ne!(key, "na"); + assert!(!key.is_empty()); + } + + #[test] + fn test_should_emit_human_message_ignores_assistant_roles() { + let assistant = test_agent_run_result_for_telemetry(&[ + ("role", "assistant"), + ("prompt_char_count", "42"), + ]); + assert!(!should_emit_human_message(&assistant, "message.updated")); + + let human = + test_agent_run_result_for_telemetry(&[("role", "user"), ("prompt_char_count", "42")]); + assert!(should_emit_human_message(&human, "message.updated")); + } + + #[test] + fn test_normalized_hook_attrs_uses_session_id_for_prompt_identity() { + let result = test_agent_run_result_for_telemetry(&[ + ("agent_tool", "github-copilot"), + ("session_id", "copilot-session-123"), + ]); + + let attrs = normalized_hook_attrs( + crate::metrics::EventAttributes::with_version("1.0.0") + .tool("human") + .prompt_id("old") + .external_prompt_id("old"), + &result, + ); + + let expected_prompt_id = generate_short_hash("copilot-session-123", "github-copilot"); + assert_eq!( + attrs.prompt_id, + Some(Some(expected_prompt_id)), + "prompt_id should be derived from session_id + agent_tool when available" + ); + assert_eq!( + attrs.external_prompt_id, + Some(Some("copilot-session-123".to_string())) + ); + assert_eq!(attrs.tool, Some(Some("github-copilot".to_string()))); + } } diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 18c1e743c..98ee1c46a 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -29,12 +29,197 @@ pub struct AgentRunResult { pub edited_filepaths: Option>, pub will_edit_filepaths: Option>, pub dirty_files: Option>, + pub hook_event_name: Option, + pub hook_source: Option, + pub telemetry_payload: Option>, } pub trait AgentCheckpointPreset { fn run(&self, flags: AgentCheckpointFlags) -> Result; } +fn get_str_by_keys<'a>(data: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .find_map(|key| data.get(*key).and_then(|v| v.as_str())) +} + +fn get_u64_by_keys(data: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| data.get(*key).and_then(|v| v.as_u64())) +} + +fn get_array_len_by_keys(data: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + data.get(*key) + .and_then(|v| v.as_array()) + .map(|arr| arr.len()) + }) +} + +fn add_chars_count_field( + payload: &mut HashMap, + data: &serde_json::Value, + source_keys: &[&str], + target_key: &str, +) { + if let Some(text) = get_str_by_keys(data, source_keys) { + payload.insert(target_key.to_string(), text.chars().count().to_string()); + } +} + +fn add_u64_field( + payload: &mut HashMap, + data: &serde_json::Value, + source_keys: &[&str], + target_key: &str, +) { + if let Some(value) = get_u64_by_keys(data, source_keys) { + payload.insert(target_key.to_string(), value.to_string()); + } +} + +fn add_str_field( + payload: &mut HashMap, + data: &serde_json::Value, + source_keys: &[&str], + target_key: &str, +) { + if let Some(value) = get_str_by_keys(data, source_keys) + && !value.trim().is_empty() + { + payload.insert(target_key.to_string(), value.to_string()); + } +} + +fn collect_common_hook_telemetry_payload(data: &serde_json::Value) -> HashMap { + let mut payload = HashMap::new(); + + add_str_field( + &mut payload, + data, + &["role", "message_role", "messageRole"], + "role", + ); + add_str_field( + &mut payload, + data, + &["tool_name", "toolName", "tool"], + "tool_name", + ); + add_str_field( + &mut payload, + data, + &["tool_use_id", "toolUseId", "toolUseID", "callID"], + "tool_use_id", + ); + add_str_field( + &mut payload, + data, + &["failure_type", "failureType"], + "failure_type", + ); + add_str_field(&mut payload, data, &["source"], "source"); + add_str_field(&mut payload, data, &["reason"], "reason"); + add_str_field(&mut payload, data, &["status"], "status"); + add_str_field(&mut payload, data, &["trigger"], "trigger"); + add_str_field( + &mut payload, + data, + &["subagent_type", "subagentType", "agent_type", "agentType"], + "subagent_type", + ); + add_str_field( + &mut payload, + data, + &["subagent_id", "subagentId", "agent_id", "agentId"], + "subagent_id", + ); + add_str_field( + &mut payload, + data, + &["composer_mode", "composerMode"], + "mode", + ); + add_str_field( + &mut payload, + data, + &["generation_id", "generationId", "turn_id", "turn-id"], + "generation_id", + ); + add_str_field( + &mut payload, + data, + &[ + "conversation_id", + "conversationId", + "session_id", + "sessionId", + "chat_session_id", + "chatSessionId", + "thread_id", + "threadId", + ], + "session_id", + ); + add_str_field( + &mut payload, + data, + &["message_id", "messageId", "messageID", "msg_id", "msgId"], + "message_id", + ); + add_u64_field( + &mut payload, + data, + &["duration", "duration_ms", "durationMs"], + "duration_ms", + ); + + add_chars_count_field( + &mut payload, + data, + &["prompt", "user_prompt", "userPrompt"], + "prompt_char_count", + ); + add_chars_count_field( + &mut payload, + data, + &["text", "last-assistant-message", "last_assistant_message"], + "response_char_count", + ); + add_chars_count_field(&mut payload, data, &["result"], "result_char_count"); + + if let Some(count) = get_array_len_by_keys(data, &["attachments"]) { + payload.insert("attachment_count".to_string(), count.to_string()); + } + + if let Some(tool_name) = payload.get("tool_name") + && tool_name.starts_with("mcp__") + { + payload.insert("mcp_tool_name".to_string(), tool_name.clone()); + } + + payload +} + +fn infer_skill_name_from_prompt(prompt: &str) -> Option { + for token in prompt.split_whitespace() { + if let Some(skill) = token.strip_prefix('$') + && !skill.trim().is_empty() + { + let normalized = + skill.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_'); + if !normalized.is_empty() { + return Some(normalized.to_string()); + } + } + } + None +} + +fn is_file_edit_tool_name(tool_name: &str) -> bool { + matches!(tool_name, "Write" | "Edit" | "MultiEdit" | "NotebookEdit") +} + // Claude Code to checkpoint preset pub struct ClaudePreset; @@ -57,73 +242,169 @@ impl AgentCheckpointPreset for ClaudePreset { )); } - // Extract transcript_path and cwd from the JSON - let transcript_path = hook_data - .get("transcript_path") + let hook_event_name = hook_data + .get("hook_event_name") .and_then(|v| v.as_str()) - .ok_or_else(|| { - GitAiError::PresetError("transcript_path not found in hook_input".to_string()) - })?; + .map(|s| s.to_string()); - let _cwd = hook_data - .get("cwd") + let tool_name = hook_data + .get("tool_name") .and_then(|v| v.as_str()) - .ok_or_else(|| GitAiError::PresetError("cwd not found in hook_input".to_string()))?; + .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())); - // Extract the ID from the filename - // Example: /Users/aidancunniffe/.claude/projects/-Users-aidancunniffe-Desktop-ghq/cb947e5b-246e-4253-a953-631f7e464c6b.jsonl - let path = Path::new(transcript_path); - let filename = path - .file_stem() - .and_then(|stem| stem.to_str()) - .ok_or_else(|| { - GitAiError::PresetError( - "Could not extract filename from transcript_path".to_string(), - ) - })?; + let is_tool_hook = matches!( + hook_event_name.as_deref(), + Some("PreToolUse" | "PostToolUse" | "PostToolUseFailure") + ); + let has_file_path_in_tool_input = hook_data + .get("tool_input") + .and_then(|ti| ti.get("file_path")) + .and_then(|v| v.as_str()) + .is_some(); + let is_edit_tool_hook = is_tool_hook + && (tool_name.map(is_file_edit_tool_name).unwrap_or(false) + || has_file_path_in_tool_input); + + let telemetry_only_hook = matches!( + hook_event_name.as_deref(), + Some( + "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "PermissionRequest" + | "SubagentStart" + | "SubagentStop" + | "Stop" + | "PreCompact" + | "Notification" + | "PostToolUseFailure" + ) + ) || (is_tool_hook && !is_edit_tool_hook); - // Parse into transcript and extract model - let (transcript, model) = - match ClaudePreset::transcript_and_model_from_claude_code_jsonl(transcript_path) { - Ok((transcript, model)) => (transcript, model), - Err(e) => { - eprintln!("[Warning] Failed to parse Claude JSONL: {e}"); - log_error( - &e, - Some(serde_json::json!({ - "agent_tool": "claude", - "operation": "transcript_and_model_from_claude_code_jsonl" - })), - ); - ( - crate::authorship::transcript::AiTranscript::new(), - Some("unknown".to_string()), + let transcript_path = hook_data + .get("transcript_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let _cwd = hook_data.get("cwd").and_then(|v| v.as_str()); + + if !telemetry_only_hook && transcript_path.is_none() { + return Err(GitAiError::PresetError( + "transcript_path not found in hook_input".to_string(), + )); + } + if !telemetry_only_hook && _cwd.is_none() { + return Err(GitAiError::PresetError( + "cwd not found in hook_input".to_string(), + )); + } + + let (agent_session_id, transcript, model) = if let Some(path) = transcript_path.as_deref() { + let filename = Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| { + GitAiError::PresetError( + "Could not extract filename from transcript_path".to_string(), ) + })? + .to_string(); + + let (transcript, model) = if telemetry_only_hook { + ( + crate::authorship::transcript::AiTranscript::new(), + hook_data + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + ) + } else { + match ClaudePreset::transcript_and_model_from_claude_code_jsonl(path) { + Ok((transcript, model)) => (transcript, model), + Err(e) => { + eprintln!("[Warning] Failed to parse Claude JSONL: {e}"); + log_error( + &e, + Some(serde_json::json!({ + "agent_tool": "claude", + "operation": "transcript_and_model_from_claude_code_jsonl" + })), + ); + ( + crate::authorship::transcript::AiTranscript::new(), + Some("unknown".to_string()), + ) + } } }; - // The filename should be a UUID + (filename, transcript, model) + } else { + ( + hook_data + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown-session") + .to_string(), + crate::authorship::transcript::AiTranscript::new(), + hook_data + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + ) + }; + let agent_id = AgentId { tool: "claude".to_string(), - id: filename.to_string(), + id: agent_session_id, model: model.unwrap_or_else(|| "unknown".to_string()), }; // Extract file_path from tool_input if present - let file_path_as_vec = hook_data - .get("tool_input") - .and_then(|ti| ti.get("file_path")) - .and_then(|v| v.as_str()) - .map(|path| vec![path.to_string()]); + let file_path_as_vec = if is_edit_tool_hook { + hook_data + .get("tool_input") + .and_then(|ti| ti.get("file_path")) + .and_then(|v| v.as_str()) + .map(|path| vec![path.to_string()]) + } else { + None + }; - // Store transcript_path in metadata - let agent_metadata = - HashMap::from([("transcript_path".to_string(), transcript_path.to_string())]); + // Store transcript_path in metadata when present + let agent_metadata = transcript_path + .as_deref() + .map(|path| HashMap::from([("transcript_path".to_string(), path.to_string())])); - // Check if this is a PreToolUse event (human checkpoint) - let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let hook_source = Some("claude_hook".to_string()); - if hook_event_name == Some("PreToolUse") { + let mut telemetry_payload = collect_common_hook_telemetry_payload(&hook_data); + if let Some(session_id) = hook_data.get("session_id").and_then(|v| v.as_str()) { + telemetry_payload.insert("session_id".to_string(), session_id.to_string()); + } + if let Some(tool_name) = hook_data.get("tool_name").and_then(|v| v.as_str()) + && tool_name.starts_with("mcp__") + { + telemetry_payload.insert("mcp_tool_name".to_string(), tool_name.to_string()); + } + if let Some(prompt) = get_str_by_keys(&hook_data, &["prompt", "user_prompt", "userPrompt"]) + && let Some(skill_name) = infer_skill_name_from_prompt(prompt) + { + telemetry_payload.insert("skill_name".to_string(), skill_name); + telemetry_payload.insert( + "skill_detection_method".to_string(), + "inferred_prompt".to_string(), + ); + } + if telemetry_only_hook { + telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); + } + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + + if hook_event_name.as_deref() == Some("PreToolUse") { // Early return for human checkpoint return Ok(AgentRunResult { agent_id, @@ -134,12 +415,15 @@ impl AgentCheckpointPreset for ClaudePreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_event_name, + hook_source, + telemetry_payload, }); } Ok(AgentRunResult { agent_id, - agent_metadata: Some(agent_metadata), + agent_metadata, checkpoint_kind: CheckpointKind::AiAgent, transcript: Some(transcript), // use default. @@ -147,6 +431,9 @@ impl AgentCheckpointPreset for ClaudePreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_event_name, + hook_source, + telemetry_payload, }) } } @@ -450,6 +737,9 @@ impl AgentCheckpointPreset for GeminiPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }); } @@ -463,6 +753,9 @@ impl AgentCheckpointPreset for GeminiPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }) } } @@ -658,6 +951,9 @@ impl AgentCheckpointPreset for ContinueCliPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }); } @@ -671,6 +967,9 @@ impl AgentCheckpointPreset for ContinueCliPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }) } } @@ -793,6 +1092,68 @@ impl AgentCheckpointPreset for CodexPreset { let hook_data: serde_json::Value = serde_json::from_str(&stdin_json) .map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?; + let hook_event_name = hook_data + .get("type") + .and_then(|v| v.as_str()) + .or_else(|| { + hook_data + .get("hook_event") + .and_then(|v| v.get("event_type")) + .and_then(|v| v.as_str()) + }) + .map(|s| s.to_string()); + let hook_source = Some("codex_notify".to_string()); + + let mut telemetry_payload = collect_common_hook_telemetry_payload(&hook_data); + if let Some(input_messages) = hook_data + .get("input-messages") + .or_else(|| { + hook_data + .get("hook_event") + .and_then(|v| v.get("input_messages")) + }) + .and_then(|v| v.as_array()) + { + telemetry_payload.insert( + "input_message_count".to_string(), + input_messages.len().to_string(), + ); + let total_chars: usize = input_messages + .iter() + .filter_map(|v| v.as_str()) + .map(|s| s.chars().count()) + .sum(); + telemetry_payload.insert("prompt_char_count".to_string(), total_chars.to_string()); + if let Some(first_prompt) = input_messages.iter().find_map(|v| v.as_str()) + && let Some(skill_name) = infer_skill_name_from_prompt(first_prompt) + { + telemetry_payload.insert("skill_name".to_string(), skill_name); + telemetry_payload.insert( + "skill_detection_method".to_string(), + "inferred_prompt".to_string(), + ); + } + } + if let Some(last_assistant) = hook_data + .get("last-assistant-message") + .or_else(|| { + hook_data + .get("hook_event") + .and_then(|v| v.get("last_assistant_message")) + }) + .and_then(|v| v.as_str()) + { + telemetry_payload.insert( + "response_char_count".to_string(), + last_assistant.chars().count().to_string(), + ); + } + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + let session_id = CodexPreset::session_id_from_hook_data(&hook_data).ok_or_else(|| { GitAiError::PresetError("session_id/thread_id not found in hook_input".to_string()) })?; @@ -866,6 +1227,9 @@ impl AgentCheckpointPreset for CodexPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, + hook_event_name, + hook_source, + telemetry_payload, }) } } @@ -1164,14 +1528,76 @@ impl AgentCheckpointPreset for CursorPreset { .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()); - // Validate hook_event_name - if hook_event_name != "beforeSubmitPrompt" && hook_event_name != "afterFileEdit" { + let supported_events = [ + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "subagentStart", + "subagentStop", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "beforeReadFile", + "afterFileEdit", + "afterAgentResponse", + "afterAgentThought", + "stop", + ]; + if !supported_events.contains(&hook_event_name.as_str()) { return Err(GitAiError::PresetError(format!( - "Invalid hook_event_name: {}. Expected 'beforeSubmitPrompt' or 'afterFileEdit'", + "Invalid hook_event_name: {} for Cursor preset", hook_event_name ))); } + let hook_source = Some("cursor_hook".to_string()); + let mut telemetry_payload = collect_common_hook_telemetry_payload(&hook_data); + if let Some(prompt) = hook_data.get("prompt").and_then(|v| v.as_str()) { + telemetry_payload.insert( + "prompt_char_count".to_string(), + prompt.chars().count().to_string(), + ); + if let Some(skill_name) = infer_skill_name_from_prompt(prompt) { + telemetry_payload.insert("skill_name".to_string(), skill_name); + telemetry_payload.insert( + "skill_detection_method".to_string(), + "inferred_prompt".to_string(), + ); + } + } + if let Some(attachments) = hook_data.get("attachments").and_then(|v| v.as_array()) { + telemetry_payload.insert( + "attachment_count".to_string(), + attachments.len().to_string(), + ); + } + if let Some(text) = hook_data.get("text").and_then(|v| v.as_str()) { + telemetry_payload.insert( + "response_char_count".to_string(), + text.chars().count().to_string(), + ); + } + if let Some(trigger) = hook_data.get("trigger").and_then(|v| v.as_str()) { + telemetry_payload.insert("reason".to_string(), trigger.to_string()); + } + if let Some(tool_name) = hook_data.get("tool_name").and_then(|v| v.as_str()) + && tool_name.starts_with("mcp__") + { + telemetry_payload.insert("mcp_tool_name".to_string(), tool_name.to_string()); + } + if hook_event_name != "beforeSubmitPrompt" && hook_event_name != "afterFileEdit" { + telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); + } + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + let file_path = hook_data .get("file_path") .and_then(|v| v.as_str()) @@ -1216,6 +1642,29 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, + hook_event_name: Some(hook_event_name), + hook_source, + telemetry_payload, + }); + } + + if hook_event_name != "afterFileEdit" { + return Ok(AgentRunResult { + agent_id: AgentId { + tool: "cursor".to_string(), + id: conversation_id, + model, + }, + agent_metadata: None, + checkpoint_kind: CheckpointKind::AiAgent, + transcript: None, + repo_working_dir: Some(repo_working_dir), + edited_filepaths: None, + will_edit_filepaths: None, + dirty_files: None, + hook_event_name: Some(hook_event_name), + hook_source, + telemetry_payload, }); } @@ -1292,6 +1741,9 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths, will_edit_filepaths: None, dirty_files: None, + hook_event_name: Some(hook_event_name), + hook_source, + telemetry_payload, }) } } @@ -1624,22 +2076,39 @@ impl AgentCheckpointPreset for GithubCopilotPreset { return Self::run_legacy_extension_hooks(&hook_data, hook_event_name); } - if hook_event_name == "PreToolUse" || hook_event_name == "PostToolUse" { + if Self::is_supported_vscode_hook_event(hook_event_name) { return Self::run_vscode_native_hooks(&hook_data, hook_event_name); } Err(GitAiError::PresetError(format!( - "Invalid hook_event_name: {}. Expected one of 'before_edit', 'after_edit', 'PreToolUse', or 'PostToolUse'", + "Invalid hook_event_name: {}. Expected one of 'before_edit', 'after_edit', 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PreCompact', 'SubagentStart', 'SubagentStop', or 'Stop'", hook_event_name ))) } } impl GithubCopilotPreset { + fn is_supported_vscode_hook_event(hook_event_name: &str) -> bool { + matches!( + hook_event_name, + "SessionStart" + | "UserPromptSubmit" + | "PreToolUse" + | "PostToolUse" + | "PreCompact" + | "SubagentStart" + | "SubagentStop" + | "Stop" + ) + } + fn run_legacy_extension_hooks( hook_data: &serde_json::Value, hook_event_name: &str, ) -> Result { + let hook_source = Some("github_copilot_hook".to_string()); + let telemetry_payload_common = collect_common_hook_telemetry_payload(hook_data); + let repo_working_dir: String = hook_data .get("workspace_folder") .and_then(|v| v.as_str()) @@ -1676,6 +2145,15 @@ impl GithubCopilotPreset { )); } + let mut telemetry_payload = telemetry_payload_common.clone(); + telemetry_payload.insert("tool_name".to_string(), "edit_file".to_string()); + telemetry_payload.insert("agent_tool".to_string(), "github-copilot".to_string()); + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + return Ok(AgentRunResult { agent_id: AgentId { tool: "human".to_string(), @@ -1689,6 +2167,9 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: Some(will_edit_filepaths), dirty_files, + hook_event_name: Some(hook_event_name.to_string()), + hook_source, + telemetry_payload, }); } @@ -1752,6 +2233,15 @@ impl GithubCopilotPreset { model: detected_model.unwrap_or_else(|| "unknown".to_string()), }; + let mut telemetry_payload = telemetry_payload_common; + telemetry_payload.insert("tool_name".to_string(), "edit_file".to_string()); + telemetry_payload.insert("agent_tool".to_string(), "github-copilot".to_string()); + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + Ok(AgentRunResult { agent_id, agent_metadata: Some(agent_metadata), @@ -1762,6 +2252,9 @@ impl GithubCopilotPreset { edited_filepaths: edited_filepaths.or(detected_edited_filepaths), will_edit_filepaths: None, dirty_files, + hook_event_name: Some(hook_event_name.to_string()), + hook_source, + telemetry_payload, }) } @@ -1769,13 +2262,13 @@ impl GithubCopilotPreset { hook_data: &serde_json::Value, hook_event_name: &str, ) -> Result { + let hook_source = Some("github_copilot_hook".to_string()); let cwd = hook_data .get("cwd") .and_then(|v| v.as_str()) .or_else(|| hook_data.get("workspace_folder").and_then(|v| v.as_str())) .or_else(|| hook_data.get("workspaceFolder").and_then(|v| v.as_str())) - .ok_or_else(|| GitAiError::PresetError("cwd not found in hook_input".to_string()))? - .to_string(); + .map(str::to_string); let dirty_files = Self::dirty_files_from_hook_data(hook_data); let chat_session_id = hook_data @@ -1786,6 +2279,9 @@ impl GithubCopilotPreset { .or_else(|| hook_data.get("sessionId").and_then(|v| v.as_str())) .unwrap_or("unknown") .to_string(); + let model_hint = get_str_by_keys(hook_data, &["model", "model_id", "modelId"]) + .unwrap_or("unknown") + .to_string(); let tool_name = hook_data .get("tool_name") @@ -1793,15 +2289,48 @@ impl GithubCopilotPreset { .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())) .unwrap_or("unknown"); + let mut telemetry_payload = collect_common_hook_telemetry_payload(hook_data); + telemetry_payload.insert("tool_name".to_string(), tool_name.to_string()); + telemetry_payload.insert("agent_tool".to_string(), "github-copilot".to_string()); + + if !matches!(hook_event_name, "PreToolUse" | "PostToolUse") { + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &chat_session_id, + &model_hint, + cwd, + dirty_files, + telemetry_payload, + )); + } + // VS Code currently executes imported hooks even when matcher/tool filters are ignored. - // Enforce tool filtering in git-ai to avoid creating checkpoints for read/search tools. + // Keep telemetry for all tools; only create checkpoints for known edit tools. if !Self::is_supported_vscode_edit_tool_name(tool_name) { - return Err(GitAiError::PresetError(format!( - "Skipping VS Code hook for unsupported tool_name '{}' (non-edit tool).", - tool_name - ))); + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &chat_session_id, + &model_hint, + cwd, + dirty_files, + telemetry_payload, + )); } + let Some(cwd) = cwd else { + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &chat_session_id, + &model_hint, + None, + dirty_files, + telemetry_payload, + )); + }; + let tool_input = hook_data .get("tool_input") .or_else(|| hook_data.get("toolInput")); @@ -1836,12 +2365,15 @@ impl GithubCopilotPreset { let transcript_path = Self::transcript_path_from_hook_data(hook_data).map(str::to_string); - if let Some(path) = transcript_path.as_deref() - && Self::looks_like_claude_transcript_path(path) - { - return Err(GitAiError::PresetError( - "Skipping VS Code hook because transcript_path looks like a Claude transcript path." - .to_string(), + if !Self::is_likely_copilot_native_hook(transcript_path.as_deref()) { + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &chat_session_id, + &model_hint, + Some(cwd), + dirty_files, + telemetry_payload, )); } @@ -1878,14 +2410,6 @@ impl GithubCopilotPreset { detected_model = Some(chat_sessions_model); } - if !Self::is_likely_copilot_native_hook(transcript_path.as_deref()) { - return Err(GitAiError::PresetError(format!( - "Skipping VS Code hook for non-Copilot session (tool_name: {}, model: {}).", - tool_name, - detected_model.as_deref().unwrap_or("unknown") - ))); - } - let detected_edited_filepaths = detected_edited_filepaths.map(|paths| { paths .into_iter() @@ -1901,12 +2425,23 @@ impl GithubCopilotPreset { if hook_event_name == "PreToolUse" { if extracted_paths.is_empty() { - return Err(GitAiError::PresetError(format!( - "No editable file paths found in VS Code hook input (tool_name: {}). Skipping checkpoint.", - tool_name - ))); + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &chat_session_id, + &model_hint, + Some(cwd), + dirty_files, + telemetry_payload, + )); } + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + return Ok(AgentRunResult { agent_id: AgentId { tool: "human".to_string(), @@ -1920,19 +2455,28 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: Some(extracted_paths), dirty_files, + hook_event_name: Some(hook_event_name.to_string()), + hook_source, + telemetry_payload, }); } - let transcript_path = transcript_path.ok_or_else(|| { - GitAiError::PresetError( - "transcript_path not found in hook_input for PostToolUse".to_string(), - ) - })?; + let Some(transcript_path) = transcript_path else { + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &chat_session_id, + &model_hint, + Some(cwd), + dirty_files, + telemetry_payload, + )); + }; let agent_id = AgentId { tool: "github-copilot".to_string(), id: chat_session_id, - model: detected_model.unwrap_or_else(|| "unknown".to_string()), + model: detected_model.unwrap_or(model_hint), }; let agent_metadata = HashMap::from([ @@ -1941,12 +2485,23 @@ impl GithubCopilotPreset { ]); if extracted_paths.is_empty() { - return Err(GitAiError::PresetError(format!( - "No editable file paths found in VS Code PostToolUse hook input (tool_name: {}). Skipping checkpoint.", - tool_name - ))); + return Ok(Self::telemetry_only_result( + hook_event_name, + hook_source, + &agent_id.id, + &agent_id.model, + Some(cwd), + dirty_files, + telemetry_payload, + )); } + let telemetry_payload = if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }; + Ok(AgentRunResult { agent_id, agent_metadata: Some(agent_metadata), @@ -1956,9 +2511,42 @@ impl GithubCopilotPreset { edited_filepaths: Some(extracted_paths), will_edit_filepaths: None, dirty_files, + hook_event_name: Some(hook_event_name.to_string()), + hook_source, + telemetry_payload, }) } + fn telemetry_only_result( + hook_event_name: &str, + hook_source: Option, + chat_session_id: &str, + model: &str, + repo_working_dir: Option, + dirty_files: Option>, + mut telemetry_payload: HashMap, + ) -> AgentRunResult { + telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); + + AgentRunResult { + agent_id: AgentId { + tool: "github-copilot".to_string(), + id: chat_session_id.to_string(), + model: model.to_string(), + }, + agent_metadata: None, + checkpoint_kind: CheckpointKind::AiAgent, + transcript: None, + repo_working_dir, + edited_filepaths: None, + will_edit_filepaths: None, + dirty_files, + hook_event_name: Some(hook_event_name.to_string()), + hook_source, + telemetry_payload: Some(telemetry_payload), + } + } + fn dirty_files_from_hook_data( hook_data: &serde_json::Value, ) -> Option> { @@ -1979,13 +2567,9 @@ impl GithubCopilotPreset { fn is_likely_copilot_native_hook(transcript_path: Option<&str>) -> bool { let Some(path) = transcript_path else { - return false; + return true; }; - if Self::looks_like_claude_transcript_path(path) { - return false; - } - Self::looks_like_copilot_transcript_path(path) } @@ -2568,6 +3152,9 @@ impl AgentCheckpointPreset for DroidPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }); } @@ -2581,6 +3168,9 @@ impl AgentCheckpointPreset for DroidPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }) } } @@ -3338,6 +3928,9 @@ impl AgentCheckpointPreset for AiTabPreset { edited_filepaths: None, will_edit_filepaths, dirty_files, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }); } @@ -3350,6 +3943,9 @@ impl AgentCheckpointPreset for AiTabPreset { edited_filepaths, will_edit_filepaths: None, dirty_files, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }) } } diff --git a/src/commands/checkpoint_agent/agent_v1_preset.rs b/src/commands/checkpoint_agent/agent_v1_preset.rs index d6da590eb..893652137 100644 --- a/src/commands/checkpoint_agent/agent_v1_preset.rs +++ b/src/commands/checkpoint_agent/agent_v1_preset.rs @@ -71,6 +71,9 @@ impl AgentCheckpointPreset for AgentV1Preset { repo_working_dir: Some(repo_working_dir), edited_filepaths: None, dirty_files, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }), AgentV1Input::AiAgent { edited_filepaths, @@ -93,6 +96,9 @@ impl AgentCheckpointPreset for AgentV1Preset { edited_filepaths, will_edit_filepaths: None, dirty_files, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }), } } diff --git a/src/commands/checkpoint_agent/opencode_preset.rs b/src/commands/checkpoint_agent/opencode_preset.rs index e84384aad..ae9ad7005 100644 --- a/src/commands/checkpoint_agent/opencode_preset.rs +++ b/src/commands/checkpoint_agent/opencode_preset.rs @@ -23,6 +23,12 @@ struct OpenCodeHookInput { hook_event_name: String, session_id: String, cwd: String, + #[serde(default)] + hook_source: Option, + #[serde(default)] + telemetry_payload: Option>, + #[serde(default)] + tool_name: Option, tool_input: Option, } @@ -164,6 +170,9 @@ impl AgentCheckpointPreset for OpenCodePreset { hook_event_name, session_id, cwd, + hook_source, + telemetry_payload, + tool_name, tool_input, } = hook_input; @@ -172,6 +181,38 @@ impl AgentCheckpointPreset for OpenCodePreset { .and_then(|ti| ti.file_path) .map(|path| vec![path]); + let hook_source = hook_source.or_else(|| Some("opencode_plugin".to_string())); + let mut telemetry_payload = telemetry_payload.unwrap_or_default(); + if let Some(name) = tool_name { + telemetry_payload.insert("tool_name".to_string(), name); + } + + let is_edit_event = hook_event_name == "PreToolUse" || hook_event_name == "PostToolUse"; + if !is_edit_event { + telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); + let model = telemetry_payload + .get("model") + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + return Ok(AgentRunResult { + agent_id: AgentId { + tool: "opencode".to_string(), + id: session_id, + model, + }, + agent_metadata: None, + checkpoint_kind: CheckpointKind::AiAgent, + transcript: None, + repo_working_dir: Some(cwd), + edited_filepaths: None, + will_edit_filepaths: None, + dirty_files: None, + hook_event_name: Some(hook_event_name), + hook_source, + telemetry_payload: Some(telemetry_payload), + }); + } + // Determine OpenCode path (test override can point to either root or legacy storage path) let opencode_path = if let Ok(test_path) = std::env::var("GIT_AI_OPENCODE_STORAGE_PATH") { PathBuf::from(test_path) @@ -221,6 +262,13 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_event_name: Some(hook_event_name), + hook_source, + telemetry_payload: if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }, }); } @@ -234,6 +282,13 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_event_name: Some(hook_event_name), + hook_source, + telemetry_payload: if telemetry_payload.is_empty() { + None + } else { + Some(telemetry_payload) + }, }) } } diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 6121b0e9e..0f1f46f5f 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -524,6 +524,9 @@ fn handle_checkpoint(args: &[String]) { edited_filepaths, will_edit_filepaths: None, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }); } _ => {} @@ -772,6 +775,9 @@ fn handle_checkpoint(args: &[String]) { edited_filepaths: None, repo_working_dir: Some(effective_working_dir), dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }); } @@ -1124,15 +1130,8 @@ fn emit_no_repo_agent_metrics(agent_run_result: Option<&AgentRunResult>) { let Some(result) = agent_run_result else { return; }; - if result.checkpoint_kind == CheckpointKind::Human { - return; - } let agent_id = &result.agent_id; - if !commands::checkpoint::should_emit_agent_usage(agent_id) { - return; - } - let prompt_id = generate_short_hash(&agent_id.id, &agent_id.tool); let attrs = crate::metrics::EventAttributes::with_version(env!("CARGO_PKG_VERSION")) .tool(&agent_id.tool) @@ -1140,8 +1139,14 @@ fn emit_no_repo_agent_metrics(agent_run_result: Option<&AgentRunResult>) { .prompt_id(prompt_id) .external_prompt_id(&agent_id.id); - let values = crate::metrics::AgentUsageValues::new(); - crate::metrics::record(values, attrs); + if result.checkpoint_kind != CheckpointKind::Human + && commands::checkpoint::should_emit_agent_usage(agent_id) + { + let values = crate::metrics::AgentUsageValues::new(); + crate::metrics::record(values, attrs.clone()); + } + + commands::checkpoint::emit_agent_hook_telemetry(Some(result), attrs); observability::spawn_background_flush(); } diff --git a/src/git/test_utils/mod.rs b/src/git/test_utils/mod.rs index aa977c065..1f4c78f6e 100644 --- a/src/git/test_utils/mod.rs +++ b/src/git/test_utils/mod.rs @@ -413,6 +413,9 @@ impl TmpRepo { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, + hook_event_name: None, + hook_source: None, + telemetry_payload: None, }; checkpoint( diff --git a/src/mdm/agents/claude_code.rs b/src/mdm/agents/claude_code.rs index 84d8c5372..3efdd086b 100644 --- a/src/mdm/agents/claude_code.rs +++ b/src/mdm/agents/claude_code.rs @@ -10,7 +10,57 @@ use std::path::PathBuf; // Command patterns for hooks const CLAUDE_PRE_TOOL_CMD: &str = "checkpoint claude --hook-input stdin"; +#[cfg(test)] const CLAUDE_POST_TOOL_CMD: &str = "checkpoint claude --hook-input stdin"; +const CLAUDE_LEGACY_TOOL_MATCHER: &str = "Write|Edit|MultiEdit"; + +struct ClaudeHookSpec { + hook_type: &'static str, + matcher: Option<&'static str>, +} + +const CLAUDE_HOOK_SPECS: &[ClaudeHookSpec] = &[ + ClaudeHookSpec { + hook_type: "SessionStart", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "SessionEnd", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "UserPromptSubmit", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "PermissionRequest", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "PreToolUse", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "PostToolUse", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "PostToolUseFailure", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "SubagentStart", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "SubagentStop", + matcher: None, + }, + ClaudeHookSpec { + hook_type: "Stop", + matcher: None, + }, +]; pub struct ClaudeCodeInstaller; @@ -66,32 +116,47 @@ impl HookInstaller for ClaudeCodeInstaller { let content = fs::read_to_string(&settings_path)?; let existing: Value = serde_json::from_str(&content).unwrap_or_else(|_| json!({})); - // Check if our hooks are installed - let has_hooks = existing - .get("hooks") - .and_then(|h| h.get("PreToolUse")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().any(|item| { - item.get("hooks") - .and_then(|h| h.as_array()) - .map(|hooks| { - hooks.iter().any(|hook| { - hook.get("command") - .and_then(|c| c.as_str()) - .map(is_git_ai_checkpoint_command) - .unwrap_or(false) - }) - }) - .unwrap_or(false) + let has_hook_for_spec = |spec: &ClaudeHookSpec| { + existing + .get("hooks") + .and_then(|h| h.get(spec.hook_type)) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|item| { + let matcher_matches = match spec.matcher { + Some(expected) => item + .get("matcher") + .and_then(|m| m.as_str()) + .map(|m| m == expected) + .unwrap_or(false), + None => true, + }; + + matcher_matches + && item + .get("hooks") + .and_then(|h| h.as_array()) + .map(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(|c| c.as_str()) + .map(is_git_ai_checkpoint_command) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + }) }) - }) - .unwrap_or(false); + .unwrap_or(false) + }; + + let has_any = CLAUDE_HOOK_SPECS.iter().any(has_hook_for_spec); + let has_all = CLAUDE_HOOK_SPECS.iter().all(has_hook_for_spec); Ok(HookCheckResult { tool_installed: true, - hooks_installed: has_hooks, - hooks_up_to_date: has_hooks, // If installed, assume up to date for now + hooks_installed: has_any, + hooks_up_to_date: has_all, }) } @@ -121,60 +186,77 @@ impl HookInstaller for ClaudeCodeInstaller { serde_json::from_str(&existing_content)? }; - // Build commands with absolute path - let pre_tool_cmd = format!("{} {}", params.binary_path.display(), CLAUDE_PRE_TOOL_CMD); - let post_tool_cmd = format!("{} {}", params.binary_path.display(), CLAUDE_POST_TOOL_CMD); - - let desired_hooks = json!({ - "PreToolUse": { - "matcher": "Write|Edit|MultiEdit", - "desired_cmd": pre_tool_cmd, - }, - "PostToolUse": { - "matcher": "Write|Edit|MultiEdit", - "desired_cmd": post_tool_cmd, - } - }); + // Build command with absolute path + let checkpoint_cmd = format!("{} {}", params.binary_path.display(), CLAUDE_PRE_TOOL_CMD); // Merge desired into existing let mut merged = existing.clone(); let mut hooks_obj = merged.get("hooks").cloned().unwrap_or_else(|| json!({})); - // Process both PreToolUse and PostToolUse - for hook_type in &["PreToolUse", "PostToolUse"] { - let desired_matcher = desired_hooks[hook_type]["matcher"].as_str().unwrap(); - let desired_cmd = desired_hooks[hook_type]["desired_cmd"].as_str().unwrap(); + for spec in CLAUDE_HOOK_SPECS { + let hook_type = spec.hook_type; + let desired_matcher = spec.matcher; + let desired_cmd = checkpoint_cmd.as_str(); // Get or create the hooks array for this type let mut hook_type_array = hooks_obj - .get(*hook_type) + .get(hook_type) .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); - // Find existing matcher block for Write|Edit|MultiEdit + // Find existing matcher block let mut found_matcher_idx: Option = None; for (idx, item) in hook_type_array.iter().enumerate() { - if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) - && matcher == desired_matcher - { - found_matcher_idx = Some(idx); - break; + match desired_matcher { + Some(expected) => { + if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) + && matcher == expected + { + found_matcher_idx = Some(idx); + break; + } + } + None => { + if item.get("matcher").is_none() { + found_matcher_idx = Some(idx); + break; + } + + if matches!( + hook_type, + "PreToolUse" | "PostToolUse" | "PostToolUseFailure" + ) && item.get("matcher").and_then(|m| m.as_str()) + == Some(CLAUDE_LEGACY_TOOL_MATCHER) + { + found_matcher_idx = Some(idx); + break; + } + } } } let matcher_idx = match found_matcher_idx { Some(idx) => idx, None => { - // Create new matcher block - hook_type_array.push(json!({ - "matcher": desired_matcher, - "hooks": [] - })); + let mut block = json!({ "hooks": [] }); + if let Some(matcher) = desired_matcher + && let Some(obj) = block.as_object_mut() + { + obj.insert("matcher".to_string(), json!(matcher)); + } + hook_type_array.push(block); hook_type_array.len() - 1 } }; + // For unfiltered hooks, migrate legacy matcher blocks to intercept all tools. + if desired_matcher.is_none() + && let Some(obj) = hook_type_array[matcher_idx].as_object_mut() + { + obj.remove("matcher"); + } + // Get the hooks array within this matcher block let mut hooks_array = hook_type_array[matcher_idx] .get("hooks") @@ -239,7 +321,7 @@ impl HookInstaller for ClaudeCodeInstaller { // Write back the updated hook_type_array if let Some(obj) = hooks_obj.as_object_mut() { - obj.insert(hook_type.to_string(), Value::Array(hook_type_array)); + obj.insert(spec.hook_type.to_string(), Value::Array(hook_type_array)); } } @@ -289,12 +371,23 @@ impl HookInstaller for ClaudeCodeInstaller { let mut changed = false; - // Remove git-ai checkpoint commands from both PreToolUse and PostToolUse - for hook_type in &["PreToolUse", "PostToolUse"] { - if let Some(hook_type_array) = - hooks_obj.get_mut(*hook_type).and_then(|v| v.as_array_mut()) + // Remove git-ai checkpoint commands from all managed hook types + for spec in CLAUDE_HOOK_SPECS { + if let Some(hook_type_array) = hooks_obj + .get_mut(spec.hook_type) + .and_then(|v| v.as_array_mut()) { for matcher_block in hook_type_array.iter_mut() { + if let Some(expected_matcher) = spec.matcher + && matcher_block + .get("matcher") + .and_then(|m| m.as_str()) + .map(|m| m != expected_matcher) + .unwrap_or(true) + { + continue; + } + if let Some(hooks_array) = matcher_block .get_mut("hooks") .and_then(|h| h.as_array_mut()) @@ -365,7 +458,6 @@ mod tests { "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", @@ -376,7 +468,6 @@ mod tests { ], "PostToolUse": [ { - "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", @@ -403,14 +494,13 @@ mod tests { assert_eq!(pre_tool.len(), 1); assert_eq!(post_tool.len(), 1); - - assert_eq!( - pre_tool[0].get("matcher").unwrap().as_str().unwrap(), - "Write|Edit|MultiEdit" + assert!( + pre_tool[0].get("matcher").is_none(), + "PreToolUse should install without matcher" ); - assert_eq!( - post_tool[0].get("matcher").unwrap().as_str().unwrap(), - "Write|Edit|MultiEdit" + assert!( + post_tool[0].get("matcher").is_none(), + "PostToolUse should install without matcher" ); } diff --git a/src/mdm/agents/cursor.rs b/src/mdm/agents/cursor.rs index aaf75352b..aab6187e9 100644 --- a/src/mdm/agents/cursor.rs +++ b/src/mdm/agents/cursor.rs @@ -16,6 +16,24 @@ use std::path::PathBuf; // Command patterns for hooks const CURSOR_BEFORE_SUBMIT_CMD: &str = "checkpoint cursor --hook-input stdin"; const CURSOR_AFTER_EDIT_CMD: &str = "checkpoint cursor --hook-input stdin"; +const CURSOR_HOOK_EVENTS: &[&str] = &[ + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "subagentStart", + "subagentStop", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "afterFileEdit", + "afterAgentResponse", + "afterAgentThought", + "stop", +]; pub struct CursorInstaller; @@ -84,24 +102,29 @@ impl HookInstaller for CursorInstaller { let content = fs::read_to_string(&hooks_path)?; let existing: Value = serde_json::from_str(&content).unwrap_or_else(|_| json!({})); - let has_hooks = existing - .get("hooks") - .and_then(|h| h.get("beforeSubmitPrompt")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().any(|hook| { - hook.get("command") - .and_then(|c| c.as_str()) - .map(Self::is_cursor_checkpoint_command) - .unwrap_or(false) + let has_hook_for = |hook_name: &&str| { + existing + .get("hooks") + .and_then(|h| h.get(*hook_name)) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|hook| { + hook.get("command") + .and_then(|c| c.as_str()) + .map(Self::is_cursor_checkpoint_command) + .unwrap_or(false) + }) }) - }) - .unwrap_or(false); + .unwrap_or(false) + }; + + let has_any = CURSOR_HOOK_EVENTS.iter().any(has_hook_for); + let has_all = CURSOR_HOOK_EVENTS.iter().all(has_hook_for); Ok(HookCheckResult { tool_installed: true, - hooks_installed: has_hooks, - hooks_up_to_date: has_hooks, + hooks_installed: has_any, + hooks_up_to_date: has_all, }) } @@ -131,30 +154,17 @@ impl HookInstaller for CursorInstaller { serde_json::from_str(&existing_content)? }; - // Build commands with absolute path let before_submit_cmd = format!( "{} {}", params.binary_path.display(), CURSOR_BEFORE_SUBMIT_CMD ); let after_edit_cmd = format!("{} {}", params.binary_path.display(), CURSOR_AFTER_EDIT_CMD); - - // Desired hooks payload for Cursor - let desired: Value = json!({ - "version": 1, - "hooks": { - "beforeSubmitPrompt": [ - { - "command": before_submit_cmd - } - ], - "afterFileEdit": [ - { - "command": after_edit_cmd - } - ] - } - }); + let desired_cmd = if before_submit_cmd == after_edit_cmd { + before_submit_cmd + } else { + after_edit_cmd + }; // Merge desired into existing let mut merged = existing.clone(); @@ -169,15 +179,7 @@ impl HookInstaller for CursorInstaller { // Merge hooks object let mut hooks_obj = merged.get("hooks").cloned().unwrap_or_else(|| json!({})); - // Process both hook types - for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { - let desired_hooks = desired - .get("hooks") - .and_then(|h| h.get(*hook_name)) - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - + for hook_name in CURSOR_HOOK_EVENTS { // Get existing hooks array for this hook type let mut existing_hooks = hooks_obj .get(*hook_name) @@ -185,42 +187,42 @@ impl HookInstaller for CursorInstaller { .cloned() .unwrap_or_default(); - // Update outdated git-ai checkpoint commands (or add if missing) - for desired_hook in desired_hooks { - let desired_cmd = desired_hook.get("command").and_then(|c| c.as_str()); - if desired_cmd.is_none() { - continue; - } - let desired_cmd = desired_cmd.unwrap(); - - // Look for existing git-ai checkpoint cursor commands - let mut found_idx = None; - let mut needs_update = false; + let mut found_idx = None; + let mut needs_update = false; - for (idx, existing_hook) in existing_hooks.iter().enumerate() { - if let Some(existing_cmd) = - existing_hook.get("command").and_then(|c| c.as_str()) - && Self::is_cursor_checkpoint_command(existing_cmd) - { - found_idx = Some(idx); - if existing_cmd != desired_cmd { - needs_update = true; - } - break; + for (idx, existing_hook) in existing_hooks.iter().enumerate() { + if let Some(existing_cmd) = existing_hook.get("command").and_then(|c| c.as_str()) + && Self::is_cursor_checkpoint_command(existing_cmd) + { + found_idx = Some(idx); + if existing_cmd != desired_cmd { + needs_update = true; } + break; } + } - match found_idx { - Some(idx) if needs_update => { - existing_hooks[idx] = desired_hook.clone(); - } - Some(_) => { - // Already up to date, skip - } - None => { - // No existing command, add new one - existing_hooks.push(desired_hook.clone()); + match found_idx { + Some(idx) => { + if needs_update { + existing_hooks[idx] = json!({ "command": desired_cmd.clone() }); } + let keep_idx = idx; + let mut current_idx = 0; + existing_hooks.retain(|hook| { + let keep = if current_idx == keep_idx { + true + } else if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) { + !Self::is_cursor_checkpoint_command(cmd) + } else { + true + }; + current_idx += 1; + keep + }); + } + None => { + existing_hooks.push(json!({ "command": desired_cmd.clone() })); } } @@ -275,8 +277,8 @@ impl HookInstaller for CursorInstaller { let mut changed = false; - // Remove git-ai checkpoint cursor commands from both hook types - for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + // Remove git-ai checkpoint cursor commands from managed hook types + for hook_name in CURSOR_HOOK_EVENTS { if let Some(hooks_array) = hooks_obj.get_mut(*hook_name).and_then(|v| v.as_array_mut()) { let original_len = hooks_array.len(); diff --git a/src/mdm/agents/github_copilot.rs b/src/mdm/agents/github_copilot.rs index 07f89c74e..993c25fa6 100644 --- a/src/mdm/agents/github_copilot.rs +++ b/src/mdm/agents/github_copilot.rs @@ -9,8 +9,17 @@ use serde_json::{Value, json}; use std::fs; use std::path::PathBuf; -const GITHUB_COPILOT_PRE_TOOL_CMD: &str = "checkpoint github-copilot --hook-input stdin"; -const GITHUB_COPILOT_POST_TOOL_CMD: &str = "checkpoint github-copilot --hook-input stdin"; +const GITHUB_COPILOT_HOOK_CMD: &str = "checkpoint github-copilot --hook-input stdin"; +const GITHUB_COPILOT_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PreCompact", + "SubagentStart", + "SubagentStop", + "Stop", +]; pub struct GitHubCopilotInstaller; @@ -81,77 +90,51 @@ impl HookInstaller for GitHubCopilotInstaller { let content = fs::read_to_string(&hooks_path)?; let existing: Value = serde_json::from_str(&content).unwrap_or_else(|_| json!({})); - let pre_desired = format!( + let desired_cmd = format!( "{} {}", params.binary_path.display(), - GITHUB_COPILOT_PRE_TOOL_CMD - ); - let post_desired = format!( - "{} {}", - params.binary_path.display(), - GITHUB_COPILOT_POST_TOOL_CMD + GITHUB_COPILOT_HOOK_CMD ); - let has_pre_installed = existing - .get("hooks") - .and_then(|h| h.get("PreToolUse")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().any(|hook| { - hook.get("command") - .and_then(|c| c.as_str()) - .map(Self::is_github_copilot_checkpoint_command) - .unwrap_or(false) - }) - }) - .unwrap_or(false); - - let has_post_installed = existing - .get("hooks") - .and_then(|h| h.get("PostToolUse")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().any(|hook| { - hook.get("command") - .and_then(|c| c.as_str()) - .map(Self::is_github_copilot_checkpoint_command) - .unwrap_or(false) - }) - }) - .unwrap_or(false); - - let has_pre_up_to_date = existing - .get("hooks") - .and_then(|h| h.get("PreToolUse")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().any(|hook| { - hook.get("command") - .and_then(|c| c.as_str()) - .map(|cmd| cmd == pre_desired) - .unwrap_or(false) + let has_hook_for = |hook_name: &&str| { + existing + .get("hooks") + .and_then(|h| h.get(*hook_name)) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|hook| { + hook.get("command") + .and_then(|c| c.as_str()) + .map(Self::is_github_copilot_checkpoint_command) + .unwrap_or(false) + }) }) - }) - .unwrap_or(false); - - let has_post_up_to_date = existing - .get("hooks") - .and_then(|h| h.get("PostToolUse")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().any(|hook| { - hook.get("command") - .and_then(|c| c.as_str()) - .map(|cmd| cmd == post_desired) - .unwrap_or(false) + .unwrap_or(false) + }; + + let up_to_date_for = |hook_name: &&str| { + existing + .get("hooks") + .and_then(|h| h.get(*hook_name)) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|hook| { + hook.get("command") + .and_then(|c| c.as_str()) + .map(|cmd| cmd == desired_cmd.as_str()) + .unwrap_or(false) + }) }) - }) - .unwrap_or(false); + .unwrap_or(false) + }; + + let has_any = GITHUB_COPILOT_HOOK_EVENTS.iter().any(has_hook_for); + let has_all = GITHUB_COPILOT_HOOK_EVENTS.iter().all(up_to_date_for); Ok(HookCheckResult { tool_installed: true, - hooks_installed: has_pre_installed || has_post_installed, - hooks_up_to_date: has_pre_up_to_date && has_post_up_to_date, + hooks_installed: has_any, + hooks_up_to_date: has_all, }) } @@ -178,34 +161,12 @@ impl HookInstaller for GitHubCopilotInstaller { serde_json::from_str(&existing_content)? }; - let pre_tool_cmd = format!( - "{} {}", - params.binary_path.display(), - GITHUB_COPILOT_PRE_TOOL_CMD - ); - let post_tool_cmd = format!( + let desired_cmd = format!( "{} {}", params.binary_path.display(), - GITHUB_COPILOT_POST_TOOL_CMD + GITHUB_COPILOT_HOOK_CMD ); - let desired: Value = json!({ - "hooks": { - "PreToolUse": [ - { - "type": "command", - "command": pre_tool_cmd - } - ], - "PostToolUse": [ - { - "type": "command", - "command": post_tool_cmd - } - ] - } - }); - let mut merged = existing.clone(); if !merged.is_object() { merged = json!({}); @@ -217,21 +178,11 @@ impl HookInstaller for GitHubCopilotInstaller { None => json!({}), }; - for hook_name in &["PreToolUse", "PostToolUse"] { - let desired_hook = desired - .get("hooks") - .and_then(|h| h.get(*hook_name)) - .and_then(|v| v.as_array()) - .and_then(|arr| arr.first()) - .cloned(); - let Some(desired_hook) = desired_hook else { - continue; - }; - - let desired_cmd = desired_hook.get("command").and_then(|c| c.as_str()); - let Some(desired_cmd) = desired_cmd else { - continue; - }; + for hook_name in GITHUB_COPILOT_HOOK_EVENTS { + let desired_hook = json!({ + "type": "command", + "command": desired_cmd + }); let mut existing_hooks = hooks_obj .get(*hook_name) @@ -324,7 +275,7 @@ impl HookInstaller for GitHubCopilotInstaller { let mut changed = false; - for hook_name in &["PreToolUse", "PostToolUse"] { + for hook_name in GITHUB_COPILOT_HOOK_EVENTS { if let Some(hooks_array) = hooks_obj.get_mut(*hook_name).and_then(|v| v.as_array_mut()) { let original_len = hooks_array.len(); @@ -365,6 +316,7 @@ mod tests { use super::*; use crate::mdm::hook_installer::HookInstaller; use serial_test::serial; + use std::ffi::OsString; use std::path::Path; use tempfile::tempdir; @@ -372,12 +324,35 @@ mod tests { PathBuf::from("/tmp/git-ai/bin/git-ai") } + struct EnvRestoreGuard { + prev_home: Option, + prev_userprofile: Option, + } + + impl Drop for EnvRestoreGuard { + fn drop(&mut self) { + // SAFETY: tests are serialized via #[serial], so restoring process env is safe. + unsafe { + match &self.prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.prev_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + } + } + fn with_temp_home(f: F) { let temp = tempdir().unwrap(); let home = temp.path().to_path_buf(); - let prev_home = std::env::var_os("HOME"); - let prev_userprofile = std::env::var_os("USERPROFILE"); + let _restore_guard = EnvRestoreGuard { + prev_home: std::env::var_os("HOME"), + prev_userprofile: std::env::var_os("USERPROFILE"), + }; // SAFETY: tests are serialized via #[serial], so mutating process env is safe. unsafe { @@ -386,20 +361,7 @@ mod tests { } f(&home); - - // SAFETY: tests are serialized via #[serial], so restoring process env is safe. - unsafe { - match prev_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - match prev_userprofile { - Some(v) => std::env::set_var("USERPROFILE", v), - None => std::env::remove_var("USERPROFILE"), - } - } } - #[test] fn test_github_copilot_installer_name() { let installer = GitHubCopilotInstaller; @@ -430,23 +392,24 @@ mod tests { let content: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()) .expect("valid json"); - let pre = content - .get("hooks") - .and_then(|h| h.get("PreToolUse")) - .and_then(|v| v.as_array()) - .unwrap(); - let post = content - .get("hooks") - .and_then(|h| h.get("PostToolUse")) - .and_then(|v| v.as_array()) - .unwrap(); - - assert_eq!(pre.len(), 1); - assert_eq!(post.len(), 1); - assert_eq!( - pre[0].get("command").and_then(|v| v.as_str()), - Some("/tmp/git-ai/bin/git-ai checkpoint github-copilot --hook-input stdin") - ); + for hook_name in GITHUB_COPILOT_HOOK_EVENTS { + let hook_entries = content + .get("hooks") + .and_then(|h| h.get(*hook_name)) + .and_then(|v| v.as_array()) + .unwrap(); + + assert_eq!( + hook_entries.len(), + 1, + "{} should have one git-ai hook entry", + hook_name + ); + assert_eq!( + hook_entries[0].get("command").and_then(|v| v.as_str()), + Some("/tmp/git-ai/bin/git-ai checkpoint github-copilot --hook-input stdin") + ); + } }); } @@ -508,19 +471,19 @@ mod tests { .expect("valid json"); assert_eq!(content.get("extra").and_then(|v| v.as_str()), Some("keep")); - let pre = content - .get("hooks") - .and_then(|h| h.get("PreToolUse")) - .and_then(|v| v.as_array()) - .unwrap(); - let post = content - .get("hooks") - .and_then(|h| h.get("PostToolUse")) - .and_then(|v| v.as_array()) - .unwrap(); - - assert_eq!(pre.len(), 1); - assert_eq!(post.len(), 1); + for hook_name in GITHUB_COPILOT_HOOK_EVENTS { + let hook_entries = content + .get("hooks") + .and_then(|h| h.get(*hook_name)) + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!( + hook_entries.len(), + 1, + "{} should have one git-ai hook entry", + hook_name + ); + } }); } @@ -542,10 +505,17 @@ mod tests { let content: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()) .expect("valid json"); - let hooks = content.get("hooks").and_then(|v| v.as_object()); - assert!(hooks.is_some()); - assert!(hooks.unwrap().contains_key("PreToolUse")); - assert!(hooks.unwrap().contains_key("PostToolUse")); + let hooks = content + .get("hooks") + .and_then(|v| v.as_object()) + .expect("hooks object should exist"); + for hook_name in GITHUB_COPILOT_HOOK_EVENTS { + assert!( + hooks.contains_key(*hook_name), + "Missing hook key {}", + hook_name + ); + } }); } @@ -596,6 +566,12 @@ mod tests { "type": "command", "command": "/tmp/git-ai/bin/git-ai checkpoint github-copilot --hook-input stdin" } + ], + "Stop": [ + { + "type": "command", + "command": "/tmp/git-ai/bin/git-ai checkpoint github-copilot --hook-input stdin" + } ] } }); @@ -667,14 +643,22 @@ mod tests { .get("hooks") .and_then(|h| h.get("PostToolUse")) .and_then(|v| v.as_array()) - .unwrap(); + .map(|arr| arr.len()) + .unwrap_or(0); + let stop = content + .get("hooks") + .and_then(|h| h.get("Stop")) + .and_then(|v| v.as_array()) + .map(|arr| arr.len()) + .unwrap_or(0); assert_eq!(pre.len(), 1); assert_eq!( pre[0].get("command").and_then(|v| v.as_str()), Some("echo before") ); - assert!(post.is_empty()); + assert_eq!(post, 0); + assert_eq!(stop, 0); }); } } diff --git a/src/mdm/agents/opencode.rs b/src/mdm/agents/opencode.rs index 500d2c270..107368649 100644 --- a/src/mdm/agents/opencode.rs +++ b/src/mdm/agents/opencode.rs @@ -175,6 +175,11 @@ mod tests { let content = fs::read_to_string(&plugin_path).unwrap(); assert!(content.contains("GitAiPlugin")); + assert!(content.contains("session.created")); + assert!(content.contains("session.deleted")); + assert!(content.contains("session.idle")); + assert!(content.contains("message.updated")); + assert!(content.contains("message.part.updated")); assert!(content.contains("tool.execute.before")); assert!(content.contains("tool.execute.after")); // Uses the opencode preset with session_id-based hook input and absolute path @@ -193,12 +198,16 @@ mod tests { assert!(content.contains("export const GitAiPlugin: Plugin")); assert!(content.contains("\"tool.execute.before\"")); assert!(content.contains("\"tool.execute.after\"")); + assert!(content.contains("\"session.created\"")); + assert!(content.contains("\"message.updated\"")); assert!(content.contains("FILE_EDIT_TOOLS")); assert!(content.contains("edit")); assert!(content.contains("write")); // Template contains placeholder for binary path assert!(content.contains("__GIT_AI_BINARY_PATH__")); assert!(content.contains("hook_event_name")); + assert!(content.contains("hook_source")); + assert!(content.contains("telemetry_payload")); assert!(content.contains("session_id")); assert!(content.contains("PreToolUse")); assert!(content.contains("PostToolUse")); diff --git a/src/mdm/agents/vscode.rs b/src/mdm/agents/vscode.rs index e994702ae..37566eec4 100644 --- a/src/mdm/agents/vscode.rs +++ b/src/mdm/agents/vscode.rs @@ -55,8 +55,8 @@ impl HookInstaller for VSCodeInstaller { ))); } - // VS Code hooks are installed via extension, not config files - // Check if extension is installed + // VS Code extension installation is managed here. + // Native GitHub Copilot agent hooks are managed by GitHubCopilotInstaller. if let Some(cli) = &resolved_cli { match is_vsc_editor_extension_installed(cli, "git-ai.git-ai-vscode") { Ok(true) => { @@ -88,8 +88,8 @@ impl HookInstaller for VSCodeInstaller { _params: &HookInstallerParams, _dry_run: bool, ) -> Result, GitAiError> { - // VS Code doesn't have config file hooks, only extension - // The install_extras method handles the extension installation + // VS Code extension install is handled by install_extras. + // Native GitHub Copilot agent hooks are handled by GitHubCopilotInstaller. Ok(None) } @@ -98,8 +98,8 @@ impl HookInstaller for VSCodeInstaller { _params: &HookInstallerParams, _dry_run: bool, ) -> Result, GitAiError> { - // VS Code doesn't have config file hooks to uninstall - // The extension must be uninstalled manually through the editor + // VS Code extension uninstall remains manual through the editor. + // Native GitHub Copilot agent hook config is handled by GitHubCopilotInstaller. Ok(None) } diff --git a/src/mdm/utils.rs b/src/mdm/utils.rs index bcd6cb9a6..4ce893781 100644 --- a/src/mdm/utils.rs +++ b/src/mdm/utils.rs @@ -4,9 +4,12 @@ use crate::utils::debug_log; use jsonc_parser::ParseOptions; use jsonc_parser::cst::CstRootNode; use std::fs; +use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; // Minimum version requirements pub const MIN_CURSOR_VERSION: (u32, u32) = (1, 7); @@ -374,20 +377,64 @@ pub fn home_dir() -> PathBuf { /// Write data to a file atomically (write to temp, then rename) /// If the path is a symlink, writes to the target file (preserving the symlink) pub fn write_atomic(path: &Path, data: &[u8]) -> Result<(), GitAiError> { + static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + let target_path = if path.is_symlink() { fs::canonicalize(path)? } else { path.to_path_buf() }; - let tmp_path = target_path.with_extension("tmp"); - { - let mut file = fs::File::create(&tmp_path)?; + let parent = target_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let file_name = target_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("settings"); + + for _ in 0..32 { + let ts_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let tmp_path = parent.join(format!( + ".{}.{}.{}.{}.tmp", + file_name, + std::process::id(), + ts_nanos, + seq + )); + + let mut file = match OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp_path) + { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.into()), + }; + file.write_all(data)?; file.sync_all()?; + drop(file); + + match fs::rename(&tmp_path, &target_path) { + Ok(()) => return Ok(()), + Err(e) => { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + } } - fs::rename(&tmp_path, &target_path)?; - Ok(()) + + Err(GitAiError::Generic(format!( + "Failed to create temporary file for atomic write: {}", + target_path.display() + ))) } /// Ensure parent directory exists @@ -799,6 +846,8 @@ pub fn update_vscode_chat_hook_settings( mod tests { use super::*; use std::fs; + use std::sync::{Arc, Barrier}; + use std::thread; use tempfile::TempDir; #[test] @@ -1080,6 +1129,59 @@ mod tests { assert!(!file_path.is_symlink()); } + #[test] + fn test_write_atomic_concurrent_writes_same_file() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("shared.json"); + fs::write(&file_path, "{}").unwrap(); + + let path = Arc::new(file_path); + let barrier = Arc::new(Barrier::new(8)); + let mut payloads = Vec::new(); + let mut handles = Vec::new(); + + for i in 0..8 { + let payload = format!(r#"{{"writer":{}}}"#, i); + payloads.push(payload.clone()); + + let path = Arc::clone(&path); + let barrier = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + barrier.wait(); + write_atomic(path.as_ref(), payload.as_bytes()).unwrap(); + })); + } + + for handle in handles { + handle.join().expect("writer thread should complete"); + } + + let final_content = fs::read_to_string(path.as_ref()).unwrap(); + assert!( + payloads.contains(&final_content), + "final content should match exactly one writer payload: {}", + final_content + ); + + let leftover_tmp_files: Vec = fs::read_dir(temp_dir.path()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".tmp") { + Some(name) + } else { + None + } + }) + .collect(); + assert!( + leftover_tmp_files.is_empty(), + "no temporary files should be left behind: {:?}", + leftover_tmp_files + ); + } + #[test] #[cfg(unix)] fn test_write_atomic_preserves_symlink() { diff --git a/src/metrics/attrs.rs b/src/metrics/attrs.rs index 3f2795990..6bb0d4414 100644 --- a/src/metrics/attrs.rs +++ b/src/metrics/attrs.rs @@ -15,6 +15,8 @@ pub mod attr_pos { pub const MODEL: usize = 21; pub const PROMPT_ID: usize = 22; pub const EXTERNAL_PROMPT_ID: usize = 23; + pub const HOOK_EVENT_NAME: usize = 24; + pub const HOOK_SOURCE: usize = 25; } /// Common attributes for all events. @@ -31,6 +33,8 @@ pub mod attr_pos { /// | 21 | model | String | No (nullable) | /// | 22 | prompt_id | String | No (nullable) | /// | 23 | external_prompt_id | String | No (nullable) | +/// | 24 | hook_event_name | String | No (nullable) | +/// | 25 | hook_source | String | No (nullable) | #[derive(Debug, Clone, Default)] pub struct EventAttributes { pub git_ai_version: PosField, @@ -43,6 +47,8 @@ pub struct EventAttributes { pub model: PosField, pub prompt_id: PosField, pub external_prompt_id: PosField, + pub hook_event_name: PosField, + pub hook_source: PosField, } impl EventAttributes { @@ -179,6 +185,30 @@ impl EventAttributes { self.external_prompt_id = Some(None); self } + + // Builder methods for hook_event_name + pub fn hook_event_name(mut self, value: impl Into) -> Self { + self.hook_event_name = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn hook_event_name_null(mut self) -> Self { + self.hook_event_name = Some(None); + self + } + + // Builder methods for hook_source + pub fn hook_source(mut self, value: impl Into) -> Self { + self.hook_source = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn hook_source_null(mut self) -> Self { + self.hook_source = Some(None); + self + } } impl PosEncoded for EventAttributes { @@ -214,6 +244,16 @@ impl PosEncoded for EventAttributes { attr_pos::EXTERNAL_PROMPT_ID, string_to_json(&self.external_prompt_id), ); + sparse_set( + &mut map, + attr_pos::HOOK_EVENT_NAME, + string_to_json(&self.hook_event_name), + ); + sparse_set( + &mut map, + attr_pos::HOOK_SOURCE, + string_to_json(&self.hook_source), + ); map } @@ -229,6 +269,8 @@ impl PosEncoded for EventAttributes { model: sparse_get_string(arr, attr_pos::MODEL), prompt_id: sparse_get_string(arr, attr_pos::PROMPT_ID), external_prompt_id: sparse_get_string(arr, attr_pos::EXTERNAL_PROMPT_ID), + hook_event_name: sparse_get_string(arr, attr_pos::HOOK_EVENT_NAME), + hook_source: sparse_get_string(arr, attr_pos::HOOK_SOURCE), } } } @@ -248,7 +290,9 @@ mod tests { .branch("main") .tool("claude-code") .model_null() - .prompt_id("prompt-123"); + .prompt_id("prompt-123") + .hook_event_name("PreToolUse") + .hook_source("claude_hook"); assert_eq!(attrs.git_ai_version, Some(Some("1.0.0".to_string()))); assert_eq!( @@ -265,6 +309,8 @@ mod tests { assert_eq!(attrs.tool, Some(Some("claude-code".to_string()))); assert_eq!(attrs.model, Some(None)); // explicitly null assert_eq!(attrs.prompt_id, Some(Some("prompt-123".to_string()))); + assert_eq!(attrs.hook_event_name, Some(Some("PreToolUse".to_string()))); + assert_eq!(attrs.hook_source, Some(Some("claude_hook".to_string()))); } #[test] @@ -291,6 +337,8 @@ mod tests { sparse.get("22"), Some(&Value::String("prompt-123".to_string())) ); + assert_eq!(sparse.get("24"), None); // not set + assert_eq!(sparse.get("25"), None); // not set } #[test] @@ -300,6 +348,8 @@ mod tests { sparse.insert("1".to_string(), Value::Null); sparse.insert("20".to_string(), Value::String("my-tool".to_string())); sparse.insert("22".to_string(), Value::String("prompt-123".to_string())); + sparse.insert("24".to_string(), Value::String("PostToolUse".to_string())); + sparse.insert("25".to_string(), Value::String("cursor_hook".to_string())); let attrs = EventAttributes::from_sparse(&sparse); @@ -309,6 +359,8 @@ mod tests { assert_eq!(attrs.tool, Some(Some("my-tool".to_string()))); assert_eq!(attrs.model, None); // not set assert_eq!(attrs.prompt_id, Some(Some("prompt-123".to_string()))); + assert_eq!(attrs.hook_event_name, Some(Some("PostToolUse".to_string()))); + assert_eq!(attrs.hook_source, Some(Some("cursor_hook".to_string()))); } #[test] @@ -322,7 +374,9 @@ mod tests { .tool("cursor") .model("gpt-4") .prompt_id("prompt-456") - .external_prompt_id("ext-789"); + .external_prompt_id("ext-789") + .hook_event_name("afterFileEdit") + .hook_source("cursor_hook"); assert_eq!(attrs.git_ai_version, Some(Some("1.2.3".to_string()))); assert_eq!( @@ -337,6 +391,11 @@ mod tests { assert_eq!(attrs.model, Some(Some("gpt-4".to_string()))); assert_eq!(attrs.prompt_id, Some(Some("prompt-456".to_string()))); assert_eq!(attrs.external_prompt_id, Some(Some("ext-789".to_string()))); + assert_eq!( + attrs.hook_event_name, + Some(Some("afterFileEdit".to_string())) + ); + assert_eq!(attrs.hook_source, Some(Some("cursor_hook".to_string()))); } #[test] @@ -351,7 +410,9 @@ mod tests { .tool_null() .model_null() .prompt_id_null() - .external_prompt_id_null(); + .external_prompt_id_null() + .hook_event_name_null() + .hook_source_null(); assert_eq!(attrs.git_ai_version, Some(None)); assert_eq!(attrs.repo_url, Some(None)); @@ -363,6 +424,8 @@ mod tests { assert_eq!(attrs.model, Some(None)); assert_eq!(attrs.prompt_id, Some(None)); assert_eq!(attrs.external_prompt_id, Some(None)); + assert_eq!(attrs.hook_event_name, Some(None)); + assert_eq!(attrs.hook_source, Some(None)); } #[test] @@ -376,7 +439,9 @@ mod tests { .tool("test-tool") .model("test-model") .prompt_id("prompt-id") - .external_prompt_id("ext-id"); + .external_prompt_id("ext-id") + .hook_event_name("sessionStart") + .hook_source("cursor_hook"); let sparse = attrs.to_sparse(); @@ -411,6 +476,14 @@ mod tests { Some(&Value::String("prompt-id".to_string())) ); assert_eq!(sparse.get("23"), Some(&Value::String("ext-id".to_string()))); + assert_eq!( + sparse.get("24"), + Some(&Value::String("sessionStart".to_string())) + ); + assert_eq!( + sparse.get("25"), + Some(&Value::String("cursor_hook".to_string())) + ); } #[test] @@ -419,7 +492,8 @@ mod tests { .repo_url("https://gitlab.com/org/repo") .author_null() .commit_sha("sha123") - .tool("copilot"); + .tool("copilot") + .hook_event_name("PostToolUse"); let sparse = original.to_sparse(); let restored = EventAttributes::from_sparse(&sparse); @@ -432,6 +506,11 @@ mod tests { assert_eq!(restored.author, Some(None)); // explicitly null assert_eq!(restored.commit_sha, Some(Some("sha123".to_string()))); assert_eq!(restored.tool, Some(Some("copilot".to_string()))); + assert_eq!( + restored.hook_event_name, + Some(Some("PostToolUse".to_string())) + ); + assert_eq!(restored.hook_source, None); // not set assert_eq!(restored.base_commit_sha, None); // not set assert_eq!(restored.model, None); // not set } @@ -449,6 +528,7 @@ mod tests { assert_eq!(attrs.author, None); // not set assert_eq!(attrs.tool, Some(Some("windsurf".to_string()))); assert_eq!(attrs.branch, None); // not set + assert_eq!(attrs.hook_event_name, None); // not set } #[test] @@ -465,6 +545,8 @@ mod tests { assert_eq!(attrs.model, None); assert_eq!(attrs.prompt_id, None); assert_eq!(attrs.external_prompt_id, None); + assert_eq!(attrs.hook_event_name, None); + assert_eq!(attrs.hook_source, None); } #[test] @@ -488,5 +570,7 @@ mod tests { assert_eq!(MODEL, 21); assert_eq!(PROMPT_ID, 22); assert_eq!(EXTERNAL_PROMPT_ID, 23); + assert_eq!(HOOK_EVENT_NAME, 24); + assert_eq!(HOOK_SOURCE, 25); } } diff --git a/src/metrics/db.rs b/src/metrics/db.rs index a5877e375..0a7d06177 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must match MIGRATIONS.len()) -const SCHEMA_VERSION: usize = 2; +const SCHEMA_VERSION: usize = 3; /// Database migrations - each migration upgrades the schema by one version const MIGRATIONS: &[&str] = &[ @@ -27,6 +27,13 @@ const MIGRATIONS: &[&str] = &[ last_sent_ts INTEGER NOT NULL ); "#, + // Migration 2 -> 3: Generic dedupe keys for inferred hook telemetry + r#" + CREATE TABLE event_dedupe ( + event_key TEXT PRIMARY KEY, + last_emitted_ts INTEGER NOT NULL + ); + "#, ]; /// Global database singleton @@ -264,6 +271,7 @@ impl MetricsDatabase { /// Returns whether an `agent_usage` event should be emitted for this prompt_id. /// /// If emitted, this method also updates the prompt's last-sent timestamp. + #[allow(dead_code)] pub fn should_emit_agent_usage( &mut self, prompt_id: &str, @@ -301,6 +309,49 @@ impl MetricsDatabase { tx.commit()?; Ok(should_emit) } + + /// Generic dedupe utility for inferred telemetry events. + /// + /// Returns true when the event should be emitted (and stores `now_ts`), + /// false when the event is still inside the dedupe window. + #[allow(dead_code)] + pub fn should_emit_once( + &mut self, + event_key: &str, + now_ts: u64, + ttl_secs: u64, + ) -> Result { + if event_key.is_empty() { + return Ok(true); + } + + let tx = self.conn.transaction()?; + let existing_ts: Option = tx + .query_row( + "SELECT last_emitted_ts FROM event_dedupe WHERE event_key = ?1", + params![event_key], + |row| row.get(0), + ) + .optional()?; + + let should_emit = existing_ts + .map(|prev_ts| now_ts.saturating_sub(prev_ts as u64) >= ttl_secs) + .unwrap_or(true); + + if should_emit { + tx.execute( + r#" + INSERT INTO event_dedupe (event_key, last_emitted_ts) + VALUES (?1, ?2) + ON CONFLICT(event_key) DO UPDATE SET last_emitted_ts = excluded.last_emitted_ts + "#, + params![event_key, now_ts as i64], + )?; + } + + tx.commit()?; + Ok(should_emit) + } } #[cfg(test)] @@ -345,7 +396,18 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "2"); + assert_eq!(version, "3"); + + // Verify event_dedupe table exists in schema v3 + let dedupe_count: i64 = db + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='event_dedupe'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(dedupe_count, 1); } #[test] @@ -461,4 +523,75 @@ mod tests { .unwrap() ); } + + #[test] + fn test_should_emit_once_dedupe() { + let (mut db, _temp_dir) = create_test_db(); + let event_key = "cursor:thread-1:response-started"; + + assert!( + db.should_emit_once(event_key, 1_700_100_000, 86_400) + .unwrap() + ); + assert!( + !db.should_emit_once(event_key, 1_700_100_100, 86_400) + .unwrap() + ); + assert!( + db.should_emit_once(event_key, 1_700_186_401, 86_400) + .unwrap() + ); + } + + #[test] + fn test_should_emit_once_empty_key_always_true() { + let (mut db, _temp_dir) = create_test_db(); + assert!(db.should_emit_once("", 1_700_100_000, 10).unwrap()); + assert!(db.should_emit_once("", 1_700_100_001, 10).unwrap()); + } + + #[test] + fn test_migration_from_v2_to_v3_adds_event_dedupe() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("legacy-v2.db"); + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE schema_metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + INSERT INTO schema_metadata (key, value) VALUES ('version', '2'); + CREATE TABLE metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_json TEXT NOT NULL + ); + CREATE TABLE agent_usage_throttle ( + prompt_id TEXT PRIMARY KEY, + last_sent_ts INTEGER NOT NULL + ); + "#, + ) + .unwrap(); + + let mut db = MetricsDatabase { conn }; + db.initialize_schema().unwrap(); + + let version: String = db + .conn + .query_row( + "SELECT value FROM schema_metadata WHERE key = 'version'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, "3"); + + let dedupe_count: i64 = db + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='event_dedupe'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(dedupe_count, 1); + } } diff --git a/src/metrics/events.rs b/src/metrics/events.rs index 78de68c22..d330f31bd 100644 --- a/src/metrics/events.rs +++ b/src/metrics/events.rs @@ -665,6 +665,769 @@ impl EventValues for CheckpointValues { } } +/// Value positions for "agent_session" event. +pub mod agent_session_pos { + pub const PHASE: usize = 0; // String - started|ended + pub const REASON: usize = 1; // String + pub const SOURCE: usize = 2; // String + pub const MODE: usize = 3; // String + pub const DURATION_MS: usize = 4; // u64 + pub const IS_INFERRED: usize = 5; // u32 (0|1) +} + +#[derive(Debug, Clone, Default)] +pub struct AgentSessionValues { + pub phase: PosField, + pub reason: PosField, + pub source: PosField, + pub mode: PosField, + pub duration_ms: PosField, + pub is_inferred: PosField, +} + +impl AgentSessionValues { + pub fn new() -> Self { + Self::default() + } + + pub fn phase(mut self, value: impl Into) -> Self { + self.phase = Some(Some(value.into())); + self + } + + pub fn reason(mut self, value: impl Into) -> Self { + self.reason = Some(Some(value.into())); + self + } + + pub fn source(mut self, value: impl Into) -> Self { + self.source = Some(Some(value.into())); + self + } + + pub fn mode(mut self, value: impl Into) -> Self { + self.mode = Some(Some(value.into())); + self + } + + pub fn duration_ms(mut self, value: u64) -> Self { + self.duration_ms = Some(Some(value)); + self + } + + #[allow(dead_code)] + pub fn is_inferred(mut self, value: u32) -> Self { + self.is_inferred = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentSessionValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_session_pos::PHASE, + string_to_json(&self.phase), + ); + sparse_set( + &mut map, + agent_session_pos::REASON, + string_to_json(&self.reason), + ); + sparse_set( + &mut map, + agent_session_pos::SOURCE, + string_to_json(&self.source), + ); + sparse_set( + &mut map, + agent_session_pos::MODE, + string_to_json(&self.mode), + ); + sparse_set( + &mut map, + agent_session_pos::DURATION_MS, + u64_to_json(&self.duration_ms), + ); + sparse_set( + &mut map, + agent_session_pos::IS_INFERRED, + u32_to_json(&self.is_inferred), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + phase: sparse_get_string(arr, agent_session_pos::PHASE), + reason: sparse_get_string(arr, agent_session_pos::REASON), + source: sparse_get_string(arr, agent_session_pos::SOURCE), + mode: sparse_get_string(arr, agent_session_pos::MODE), + duration_ms: sparse_get_u64(arr, agent_session_pos::DURATION_MS), + is_inferred: sparse_get_u32(arr, agent_session_pos::IS_INFERRED), + } + } +} + +impl EventValues for AgentSessionValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentSession + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "agent_message" event. +pub mod agent_message_pos { + pub const ROLE: usize = 0; // String + pub const PROMPT_CHAR_COUNT: usize = 1; // u32 + pub const ATTACHMENT_COUNT: usize = 2; // u32 +} + +#[derive(Debug, Clone, Default)] +pub struct AgentMessageValues { + pub role: PosField, + pub prompt_char_count: PosField, + pub attachment_count: PosField, +} + +impl AgentMessageValues { + pub fn new() -> Self { + Self::default() + } + + pub fn role(mut self, value: impl Into) -> Self { + self.role = Some(Some(value.into())); + self + } + + pub fn prompt_char_count(mut self, value: u32) -> Self { + self.prompt_char_count = Some(Some(value)); + self + } + + pub fn attachment_count(mut self, value: u32) -> Self { + self.attachment_count = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentMessageValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_message_pos::ROLE, + string_to_json(&self.role), + ); + sparse_set( + &mut map, + agent_message_pos::PROMPT_CHAR_COUNT, + u32_to_json(&self.prompt_char_count), + ); + sparse_set( + &mut map, + agent_message_pos::ATTACHMENT_COUNT, + u32_to_json(&self.attachment_count), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + role: sparse_get_string(arr, agent_message_pos::ROLE), + prompt_char_count: sparse_get_u32(arr, agent_message_pos::PROMPT_CHAR_COUNT), + attachment_count: sparse_get_u32(arr, agent_message_pos::ATTACHMENT_COUNT), + } + } +} + +impl EventValues for AgentMessageValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentMessage + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "agent_response" event. +pub mod agent_response_pos { + pub const PHASE: usize = 0; // String - started|ended + pub const REASON: usize = 1; // String + pub const STATUS: usize = 2; // String + pub const RESPONSE_CHAR_COUNT: usize = 3; // u32 + pub const IS_INFERRED: usize = 4; // u32 (0|1) +} + +#[derive(Debug, Clone, Default)] +pub struct AgentResponseValues { + pub phase: PosField, + pub reason: PosField, + pub status: PosField, + pub response_char_count: PosField, + pub is_inferred: PosField, +} + +impl AgentResponseValues { + pub fn new() -> Self { + Self::default() + } + + pub fn phase(mut self, value: impl Into) -> Self { + self.phase = Some(Some(value.into())); + self + } + + pub fn reason(mut self, value: impl Into) -> Self { + self.reason = Some(Some(value.into())); + self + } + + pub fn status(mut self, value: impl Into) -> Self { + self.status = Some(Some(value.into())); + self + } + + pub fn response_char_count(mut self, value: u32) -> Self { + self.response_char_count = Some(Some(value)); + self + } + + pub fn is_inferred(mut self, value: u32) -> Self { + self.is_inferred = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentResponseValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_response_pos::PHASE, + string_to_json(&self.phase), + ); + sparse_set( + &mut map, + agent_response_pos::REASON, + string_to_json(&self.reason), + ); + sparse_set( + &mut map, + agent_response_pos::STATUS, + string_to_json(&self.status), + ); + sparse_set( + &mut map, + agent_response_pos::RESPONSE_CHAR_COUNT, + u32_to_json(&self.response_char_count), + ); + sparse_set( + &mut map, + agent_response_pos::IS_INFERRED, + u32_to_json(&self.is_inferred), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + phase: sparse_get_string(arr, agent_response_pos::PHASE), + reason: sparse_get_string(arr, agent_response_pos::REASON), + status: sparse_get_string(arr, agent_response_pos::STATUS), + response_char_count: sparse_get_u32(arr, agent_response_pos::RESPONSE_CHAR_COUNT), + is_inferred: sparse_get_u32(arr, agent_response_pos::IS_INFERRED), + } + } +} + +impl EventValues for AgentResponseValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentResponse + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "agent_tool_call" event. +pub mod agent_tool_call_pos { + pub const PHASE: usize = 0; // String - started|ended|failed|permission_requested + pub const TOOL_NAME: usize = 1; // String + pub const TOOL_USE_ID: usize = 2; // String + pub const DURATION_MS: usize = 3; // u64 + pub const FAILURE_TYPE: usize = 4; // String + pub const IS_INFERRED: usize = 5; // u32 (0|1) +} + +#[derive(Debug, Clone, Default)] +pub struct AgentToolCallValues { + pub phase: PosField, + pub tool_name: PosField, + pub tool_use_id: PosField, + pub duration_ms: PosField, + pub failure_type: PosField, + pub is_inferred: PosField, +} + +impl AgentToolCallValues { + pub fn new() -> Self { + Self::default() + } + + pub fn phase(mut self, value: impl Into) -> Self { + self.phase = Some(Some(value.into())); + self + } + + pub fn tool_name(mut self, value: impl Into) -> Self { + self.tool_name = Some(Some(value.into())); + self + } + + pub fn tool_use_id(mut self, value: impl Into) -> Self { + self.tool_use_id = Some(Some(value.into())); + self + } + + pub fn duration_ms(mut self, value: u64) -> Self { + self.duration_ms = Some(Some(value)); + self + } + + pub fn failure_type(mut self, value: impl Into) -> Self { + self.failure_type = Some(Some(value.into())); + self + } + + pub fn is_inferred(mut self, value: u32) -> Self { + self.is_inferred = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentToolCallValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_tool_call_pos::PHASE, + string_to_json(&self.phase), + ); + sparse_set( + &mut map, + agent_tool_call_pos::TOOL_NAME, + string_to_json(&self.tool_name), + ); + sparse_set( + &mut map, + agent_tool_call_pos::TOOL_USE_ID, + string_to_json(&self.tool_use_id), + ); + sparse_set( + &mut map, + agent_tool_call_pos::DURATION_MS, + u64_to_json(&self.duration_ms), + ); + sparse_set( + &mut map, + agent_tool_call_pos::FAILURE_TYPE, + string_to_json(&self.failure_type), + ); + sparse_set( + &mut map, + agent_tool_call_pos::IS_INFERRED, + u32_to_json(&self.is_inferred), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + phase: sparse_get_string(arr, agent_tool_call_pos::PHASE), + tool_name: sparse_get_string(arr, agent_tool_call_pos::TOOL_NAME), + tool_use_id: sparse_get_string(arr, agent_tool_call_pos::TOOL_USE_ID), + duration_ms: sparse_get_u64(arr, agent_tool_call_pos::DURATION_MS), + failure_type: sparse_get_string(arr, agent_tool_call_pos::FAILURE_TYPE), + is_inferred: sparse_get_u32(arr, agent_tool_call_pos::IS_INFERRED), + } + } +} + +impl EventValues for AgentToolCallValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentToolCall + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "agent_mcp_call" event. +pub mod agent_mcp_call_pos { + pub const PHASE: usize = 0; // String - started|ended|failed|permission_requested + pub const MCP_SERVER: usize = 1; // String + pub const TOOL_NAME: usize = 2; // String + pub const TRANSPORT: usize = 3; // String + pub const DURATION_MS: usize = 4; // u64 + pub const FAILURE_TYPE: usize = 5; // String + pub const IS_INFERRED: usize = 6; // u32 (0|1) +} + +#[derive(Debug, Clone, Default)] +pub struct AgentMcpCallValues { + pub phase: PosField, + pub mcp_server: PosField, + pub tool_name: PosField, + pub transport: PosField, + pub duration_ms: PosField, + pub failure_type: PosField, + pub is_inferred: PosField, +} + +impl AgentMcpCallValues { + pub fn new() -> Self { + Self::default() + } + + pub fn phase(mut self, value: impl Into) -> Self { + self.phase = Some(Some(value.into())); + self + } + + pub fn mcp_server(mut self, value: impl Into) -> Self { + self.mcp_server = Some(Some(value.into())); + self + } + + pub fn tool_name(mut self, value: impl Into) -> Self { + self.tool_name = Some(Some(value.into())); + self + } + + pub fn transport(mut self, value: impl Into) -> Self { + self.transport = Some(Some(value.into())); + self + } + + pub fn duration_ms(mut self, value: u64) -> Self { + self.duration_ms = Some(Some(value)); + self + } + + pub fn failure_type(mut self, value: impl Into) -> Self { + self.failure_type = Some(Some(value.into())); + self + } + + pub fn is_inferred(mut self, value: u32) -> Self { + self.is_inferred = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentMcpCallValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_mcp_call_pos::PHASE, + string_to_json(&self.phase), + ); + sparse_set( + &mut map, + agent_mcp_call_pos::MCP_SERVER, + string_to_json(&self.mcp_server), + ); + sparse_set( + &mut map, + agent_mcp_call_pos::TOOL_NAME, + string_to_json(&self.tool_name), + ); + sparse_set( + &mut map, + agent_mcp_call_pos::TRANSPORT, + string_to_json(&self.transport), + ); + sparse_set( + &mut map, + agent_mcp_call_pos::DURATION_MS, + u64_to_json(&self.duration_ms), + ); + sparse_set( + &mut map, + agent_mcp_call_pos::FAILURE_TYPE, + string_to_json(&self.failure_type), + ); + sparse_set( + &mut map, + agent_mcp_call_pos::IS_INFERRED, + u32_to_json(&self.is_inferred), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + phase: sparse_get_string(arr, agent_mcp_call_pos::PHASE), + mcp_server: sparse_get_string(arr, agent_mcp_call_pos::MCP_SERVER), + tool_name: sparse_get_string(arr, agent_mcp_call_pos::TOOL_NAME), + transport: sparse_get_string(arr, agent_mcp_call_pos::TRANSPORT), + duration_ms: sparse_get_u64(arr, agent_mcp_call_pos::DURATION_MS), + failure_type: sparse_get_string(arr, agent_mcp_call_pos::FAILURE_TYPE), + is_inferred: sparse_get_u32(arr, agent_mcp_call_pos::IS_INFERRED), + } + } +} + +impl EventValues for AgentMcpCallValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentMcpCall + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "agent_skill_usage" event. +pub mod agent_skill_usage_pos { + pub const SKILL_NAME: usize = 0; // String + pub const DETECTION_METHOD: usize = 1; // String + pub const IS_INFERRED: usize = 2; // u32 (0|1) +} + +#[derive(Debug, Clone, Default)] +pub struct AgentSkillUsageValues { + pub skill_name: PosField, + pub detection_method: PosField, + pub is_inferred: PosField, +} + +impl AgentSkillUsageValues { + pub fn new() -> Self { + Self::default() + } + + pub fn skill_name(mut self, value: impl Into) -> Self { + self.skill_name = Some(Some(value.into())); + self + } + + pub fn detection_method(mut self, value: impl Into) -> Self { + self.detection_method = Some(Some(value.into())); + self + } + + pub fn is_inferred(mut self, value: u32) -> Self { + self.is_inferred = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentSkillUsageValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_skill_usage_pos::SKILL_NAME, + string_to_json(&self.skill_name), + ); + sparse_set( + &mut map, + agent_skill_usage_pos::DETECTION_METHOD, + string_to_json(&self.detection_method), + ); + sparse_set( + &mut map, + agent_skill_usage_pos::IS_INFERRED, + u32_to_json(&self.is_inferred), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + skill_name: sparse_get_string(arr, agent_skill_usage_pos::SKILL_NAME), + detection_method: sparse_get_string(arr, agent_skill_usage_pos::DETECTION_METHOD), + is_inferred: sparse_get_u32(arr, agent_skill_usage_pos::IS_INFERRED), + } + } +} + +impl EventValues for AgentSkillUsageValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentSkillUsage + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "agent_subagent" event. +pub mod agent_subagent_pos { + pub const PHASE: usize = 0; // String - started|ended + pub const SUBAGENT_ID: usize = 1; // String + pub const SUBAGENT_TYPE: usize = 2; // String + pub const STATUS: usize = 3; // String + pub const DURATION_MS: usize = 4; // u64 + pub const RESULT_CHAR_COUNT: usize = 5; // u32 + pub const IS_INFERRED: usize = 6; // u32 (0|1) +} + +#[derive(Debug, Clone, Default)] +pub struct AgentSubagentValues { + pub phase: PosField, + pub subagent_id: PosField, + pub subagent_type: PosField, + pub status: PosField, + pub duration_ms: PosField, + pub result_char_count: PosField, + pub is_inferred: PosField, +} + +impl AgentSubagentValues { + pub fn new() -> Self { + Self::default() + } + + pub fn phase(mut self, value: impl Into) -> Self { + self.phase = Some(Some(value.into())); + self + } + + pub fn subagent_id(mut self, value: impl Into) -> Self { + self.subagent_id = Some(Some(value.into())); + self + } + + pub fn subagent_type(mut self, value: impl Into) -> Self { + self.subagent_type = Some(Some(value.into())); + self + } + + pub fn status(mut self, value: impl Into) -> Self { + self.status = Some(Some(value.into())); + self + } + + pub fn duration_ms(mut self, value: u64) -> Self { + self.duration_ms = Some(Some(value)); + self + } + + pub fn result_char_count(mut self, value: u32) -> Self { + self.result_char_count = Some(Some(value)); + self + } + + #[allow(dead_code)] + pub fn is_inferred(mut self, value: u32) -> Self { + self.is_inferred = Some(Some(value)); + self + } +} + +impl PosEncoded for AgentSubagentValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + agent_subagent_pos::PHASE, + string_to_json(&self.phase), + ); + sparse_set( + &mut map, + agent_subagent_pos::SUBAGENT_ID, + string_to_json(&self.subagent_id), + ); + sparse_set( + &mut map, + agent_subagent_pos::SUBAGENT_TYPE, + string_to_json(&self.subagent_type), + ); + sparse_set( + &mut map, + agent_subagent_pos::STATUS, + string_to_json(&self.status), + ); + sparse_set( + &mut map, + agent_subagent_pos::DURATION_MS, + u64_to_json(&self.duration_ms), + ); + sparse_set( + &mut map, + agent_subagent_pos::RESULT_CHAR_COUNT, + u32_to_json(&self.result_char_count), + ); + sparse_set( + &mut map, + agent_subagent_pos::IS_INFERRED, + u32_to_json(&self.is_inferred), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + phase: sparse_get_string(arr, agent_subagent_pos::PHASE), + subagent_id: sparse_get_string(arr, agent_subagent_pos::SUBAGENT_ID), + subagent_type: sparse_get_string(arr, agent_subagent_pos::SUBAGENT_TYPE), + status: sparse_get_string(arr, agent_subagent_pos::STATUS), + duration_ms: sparse_get_u64(arr, agent_subagent_pos::DURATION_MS), + result_char_count: sparse_get_u32(arr, agent_subagent_pos::RESULT_CHAR_COUNT), + is_inferred: sparse_get_u32(arr, agent_subagent_pos::IS_INFERRED), + } + } +} + +impl EventValues for AgentSubagentValues { + fn event_id() -> MetricEventId { + MetricEventId::AgentSubagent + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1034,4 +1797,147 @@ mod tests { assert_eq!(values.total_ai_deletions, Some(None)); assert_eq!(values.time_waiting_for_ai, Some(None)); } + + #[test] + fn test_agent_session_values_roundtrip() { + use super::PosEncoded; + + let values = AgentSessionValues::new() + .phase("started") + .source("interactive") + .mode("agent") + .is_inferred(0); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!(restored.phase, Some(Some("started".to_string()))); + assert_eq!(restored.source, Some(Some("interactive".to_string()))); + assert_eq!(restored.mode, Some(Some("agent".to_string()))); + assert_eq!(restored.is_inferred, Some(Some(0))); + assert_eq!(AgentSessionValues::event_id() as u16, 5); + } + + #[test] + fn test_agent_message_values_roundtrip() { + use super::PosEncoded; + + let values = AgentMessageValues::new() + .role("human") + .prompt_char_count(128) + .attachment_count(2); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!(restored.role, Some(Some("human".to_string()))); + assert_eq!(restored.prompt_char_count, Some(Some(128))); + assert_eq!(restored.attachment_count, Some(Some(2))); + assert_eq!(AgentMessageValues::event_id() as u16, 6); + } + + #[test] + fn test_agent_response_values_roundtrip() { + use super::PosEncoded; + + let values = AgentResponseValues::new() + .phase("ended") + .status("completed") + .response_char_count(300) + .is_inferred(1); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!(restored.phase, Some(Some("ended".to_string()))); + assert_eq!(restored.status, Some(Some("completed".to_string()))); + assert_eq!(restored.response_char_count, Some(Some(300))); + assert_eq!(restored.is_inferred, Some(Some(1))); + assert_eq!(AgentResponseValues::event_id() as u16, 7); + } + + #[test] + fn test_agent_tool_call_values_roundtrip() { + use super::PosEncoded; + + let values = AgentToolCallValues::new() + .phase("failed") + .tool_name("Write") + .tool_use_id("call-1") + .duration_ms(500) + .failure_type("timeout"); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!(restored.phase, Some(Some("failed".to_string()))); + assert_eq!(restored.tool_name, Some(Some("Write".to_string()))); + assert_eq!(restored.tool_use_id, Some(Some("call-1".to_string()))); + assert_eq!(restored.duration_ms, Some(Some(500))); + assert_eq!(restored.failure_type, Some(Some("timeout".to_string()))); + assert_eq!(AgentToolCallValues::event_id() as u16, 8); + } + + #[test] + fn test_agent_mcp_call_values_roundtrip() { + use super::PosEncoded; + + let values = AgentMcpCallValues::new() + .phase("ended") + .mcp_server("mintmcp") + .tool_name("mcp__tool") + .transport("stdio") + .duration_ms(1200); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!(restored.phase, Some(Some("ended".to_string()))); + assert_eq!(restored.mcp_server, Some(Some("mintmcp".to_string()))); + assert_eq!(restored.tool_name, Some(Some("mcp__tool".to_string()))); + assert_eq!(restored.transport, Some(Some("stdio".to_string()))); + assert_eq!(restored.duration_ms, Some(Some(1200))); + assert_eq!(AgentMcpCallValues::event_id() as u16, 9); + } + + #[test] + fn test_agent_skill_usage_values_roundtrip() { + use super::PosEncoded; + + let values = AgentSkillUsageValues::new() + .skill_name("security-review") + .detection_method("inferred_tool") + .is_inferred(1); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!( + restored.skill_name, + Some(Some("security-review".to_string())) + ); + assert_eq!( + restored.detection_method, + Some(Some("inferred_tool".to_string())) + ); + assert_eq!(restored.is_inferred, Some(Some(1))); + assert_eq!(AgentSkillUsageValues::event_id() as u16, 10); + } + + #[test] + fn test_agent_subagent_values_roundtrip() { + use super::PosEncoded; + + let values = AgentSubagentValues::new() + .phase("ended") + .subagent_id("sub-1") + .subagent_type("explore") + .status("completed") + .duration_ms(4567) + .result_char_count(500); + let sparse = PosEncoded::to_sparse(&values); + let restored = ::from_sparse(&sparse); + + assert_eq!(restored.phase, Some(Some("ended".to_string()))); + assert_eq!(restored.subagent_id, Some(Some("sub-1".to_string()))); + assert_eq!(restored.subagent_type, Some(Some("explore".to_string()))); + assert_eq!(restored.status, Some(Some("completed".to_string()))); + assert_eq!(restored.duration_ms, Some(Some(4567))); + assert_eq!(restored.result_char_count, Some(Some(500))); + assert_eq!(AgentSubagentValues::event_id() as u16, 11); + } } diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 2d33bc062..6129fb33f 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -13,7 +13,11 @@ pub mod types; // Re-export all public types for external crates pub use attrs::EventAttributes; -pub use events::{AgentUsageValues, CheckpointValues, CommittedValues, InstallHooksValues}; +pub use events::{ + AgentMcpCallValues, AgentMessageValues, AgentResponseValues, AgentSessionValues, + AgentSkillUsageValues, AgentSubagentValues, AgentToolCallValues, AgentUsageValues, + CheckpointValues, CommittedValues, InstallHooksValues, +}; pub use pos_encoded::PosEncoded; pub use types::{EventValues, METRICS_API_VERSION, MetricEvent, MetricsBatch}; diff --git a/src/metrics/types.rs b/src/metrics/types.rs index eb757072f..7a775a327 100644 --- a/src/metrics/types.rs +++ b/src/metrics/types.rs @@ -20,6 +20,13 @@ pub enum MetricEventId { AgentUsage = 2, InstallHooks = 3, Checkpoint = 4, + AgentSession = 5, + AgentMessage = 6, + AgentResponse = 7, + AgentToolCall = 8, + AgentMcpCall = 9, + AgentSkillUsage = 10, + AgentSubagent = 11, } /// Trait for event-specific values. @@ -159,6 +166,13 @@ mod tests { assert_eq!(MetricEventId::AgentUsage as u16, 2); assert_eq!(MetricEventId::InstallHooks as u16, 3); assert_eq!(MetricEventId::Checkpoint as u16, 4); + assert_eq!(MetricEventId::AgentSession as u16, 5); + assert_eq!(MetricEventId::AgentMessage as u16, 6); + assert_eq!(MetricEventId::AgentResponse as u16, 7); + assert_eq!(MetricEventId::AgentToolCall as u16, 8); + assert_eq!(MetricEventId::AgentMcpCall as u16, 9); + assert_eq!(MetricEventId::AgentSkillUsage as u16, 10); + assert_eq!(MetricEventId::AgentSubagent as u16, 11); } #[test] diff --git a/tests/agent_presets_comprehensive.rs b/tests/agent_presets_comprehensive.rs index f9c18085a..abbb6cbe3 100644 --- a/tests/agent_presets_comprehensive.rs +++ b/tests/agent_presets_comprehensive.rs @@ -50,7 +50,11 @@ fn test_claude_preset_missing_transcript_path() { let preset = ClaudePreset; let hook_input = json!({ "cwd": "/some/path", - "hook_event_name": "PostToolUse" + "hook_event_name": "PostToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "/some/file.rs" + } }) .to_string(); @@ -72,7 +76,11 @@ fn test_claude_preset_missing_cwd() { let preset = ClaudePreset; let hook_input = json!({ "transcript_path": "tests/fixtures/example-claude-code.jsonl", - "hook_event_name": "PostToolUse" + "hook_event_name": "PostToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "/some/file.rs" + } }) .to_string(); diff --git a/tests/claude_code.rs b/tests/claude_code.rs index d51160472..f4b47eae7 100644 --- a/tests/claude_code.rs +++ b/tests/claude_code.rs @@ -100,6 +100,41 @@ fn test_claude_preset_no_filepath_when_tool_input_missing() { assert!(result.edited_filepaths.is_none()); } +#[test] +fn test_claude_preset_session_start_without_transcript_is_telemetry_only() { + let hook_input = r##"{ + "hook_event_name": "SessionStart", + "session_id": "session-123", + "model": "claude-sonnet-4-5-20250929" + }"##; + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = ClaudePreset; + let result = preset + .run(flags) + .expect("SessionStart should not require transcript_path"); + + assert_eq!(result.agent_id.tool, "claude"); + assert_eq!(result.agent_id.id, "session-123"); + assert_eq!( + result.hook_event_name.as_deref(), + Some("SessionStart"), + "hook event name should be normalized onto AgentRunResult" + ); + assert_eq!(result.hook_source.as_deref(), Some("claude_hook")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + #[test] fn test_claude_preset_ignores_vscode_copilot_payload() { let hook_input = json!({ diff --git a/tests/codex.rs b/tests/codex.rs index 0c90bcacb..bcdd5f776 100644 --- a/tests/codex.rs +++ b/tests/codex.rs @@ -96,6 +96,19 @@ fn test_codex_preset_legacy_hook_input() { .is_some(), "transcript_path should be persisted for commit-time resync" ); + assert_eq!(result.hook_source.as_deref(), Some("codex_notify")); + assert_eq!( + result.hook_event_name.as_deref(), + Some("agent-turn-complete") + ); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("prompt_char_count")) + .map(String::as_str), + Some("20") + ); } #[test] @@ -140,6 +153,8 @@ fn test_codex_preset_structured_hook_input() { result.transcript.is_some(), "AI checkpoint should include transcript" ); + assert_eq!(result.hook_source.as_deref(), Some("codex_notify")); + assert_eq!(result.hook_event_name.as_deref(), Some("after_agent")); } #[test] diff --git a/tests/cursor.rs b/tests/cursor.rs index 9b4ae1002..f0873f091 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -324,6 +324,43 @@ fn test_cursor_preset_human_checkpoint_no_filepath() { assert!(result.edited_filepaths.is_none()); } +#[test] +fn test_cursor_preset_session_start_telemetry_only() { + use git_ai::authorship::working_log::CheckpointKind; + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + }; + + let hook_input = r##"{ + "conversation_id": "test-conversation-id", + "workspace_roots": ["/Users/test/workspace"], + "hook_event_name": "sessionStart", + "model": "gpt-5", + "composer_mode": "agent" + }"##; + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = CursorPreset; + let result = preset + .run(flags) + .expect("Should parse sessionStart hook payload"); + + assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); + assert_eq!(result.hook_event_name.as_deref(), Some("sessionStart")); + assert_eq!(result.hook_source.as_deref(), Some("cursor_hook")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + #[test] fn test_cursor_e2e_with_attribution() { use std::fs; diff --git a/tests/git_repository_comprehensive.rs b/tests/git_repository_comprehensive.rs index 73f418c65..50f9c9e50 100644 --- a/tests/git_repository_comprehensive.rs +++ b/tests/git_repository_comprehensive.rs @@ -781,11 +781,8 @@ fn test_git_supports_ignore_revs_file() { // Most modern git versions support this (added in 2.23.0) let supports = repo.git_supports_ignore_revs_file(); - // Just verify it returns a boolean without error - assert!( - supports || !supports, - "Should return boolean for ignore-revs-file support" - ); + // Ensure the method returns a concrete boolean value without panicking. + let _: bool = supports; } // ============================================================================ diff --git a/tests/github_copilot.rs b/tests/github_copilot.rs index 9252a2cfe..671fc173e 100644 --- a/tests/github_copilot.rs +++ b/tests/github_copilot.rs @@ -1299,19 +1299,150 @@ fn test_copilot_preset_vscode_non_edit_tool_is_filtered() { }; let preset = GithubCopilotPreset; - let result = preset.run(flags); + let result = preset + .run(flags) + .expect("Non-edit tools should emit telemetry-only checkpoint"); - assert!(result.is_err()); - assert!( + assert_eq!( + result.checkpoint_kind, + git_ai::authorship::working_log::CheckpointKind::AiAgent + ); + assert_eq!(result.will_edit_filepaths, None); + assert_eq!(result.edited_filepaths, None); + assert_eq!(result.hook_event_name.as_deref(), Some("PreToolUse")); + assert_eq!( result - .unwrap_err() - .to_string() - .contains("unsupported tool_name") + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + +#[test] +fn test_copilot_preset_vscode_session_start_is_telemetry_only() { + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, + }; + + let hook_input = json!({ + "hookEventName": "SessionStart", + "sessionId": "copilot-session-start", + "cwd": "/Users/test/project", + "source": "new", + "model": "copilot/claude-sonnet-4" + }); + + let preset = GithubCopilotPreset; + let result = preset + .run(AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }) + .expect("SessionStart should be accepted"); + + assert_eq!(result.hook_event_name.as_deref(), Some("SessionStart")); + assert_eq!(result.agent_id.tool, "github-copilot"); + assert_eq!(result.agent_id.id, "copilot-session-start"); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + +#[test] +fn test_copilot_preset_vscode_user_prompt_submit_is_telemetry_only() { + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, + }; + + let hook_input = json!({ + "hookEventName": "UserPromptSubmit", + "sessionId": "copilot-session-prompt", + "cwd": "/Users/test/project", + "prompt": "please refactor this function" + }); + + let preset = GithubCopilotPreset; + let result = preset + .run(AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }) + .expect("UserPromptSubmit should be accepted"); + + assert_eq!(result.hook_event_name.as_deref(), Some("UserPromptSubmit")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("prompt_char_count")) + .map(String::as_str), + Some("29") + ); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + +#[test] +fn test_copilot_preset_vscode_subagent_events_are_telemetry_only() { + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, + }; + + let hook_input = json!({ + "hookEventName": "SubagentStart", + "sessionId": "copilot-session-subagent", + "cwd": "/Users/test/project", + "agent_id": "subagent-123", + "agent_type": "Plan" + }); + + let preset = GithubCopilotPreset; + let result = preset + .run(AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }) + .expect("SubagentStart should be accepted"); + + assert_eq!(result.hook_event_name.as_deref(), Some("SubagentStart")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("subagent_id")) + .map(String::as_str), + Some("subagent-123") + ); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("subagent_type")) + .map(String::as_str), + Some("Plan") + ); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") ); } #[test] -fn test_copilot_preset_vscode_claude_transcript_path_is_rejected() { +fn test_copilot_preset_vscode_claude_transcript_path_is_telemetry_only() { use git_ai::commands::checkpoint_agent::agent_presets::{ AgentCheckpointFlags, AgentCheckpointPreset, }; @@ -1332,14 +1463,18 @@ fn test_copilot_preset_vscode_claude_transcript_path_is_rejected() { }; let preset = GithubCopilotPreset; - let result = preset.run(flags); + let result = preset + .run(flags) + .expect("Claude-like transcript path should fall back to telemetry-only"); - assert!(result.is_err()); + assert_eq!(result.hook_event_name.as_deref(), Some("PostToolUse")); assert!( result - .unwrap_err() - .to_string() - .contains("Claude transcript path") + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str) + == Some("1") ); } diff --git a/tests/install_hooks_comprehensive.rs b/tests/install_hooks_comprehensive.rs index 059ea3bc2..1009c3496 100644 --- a/tests/install_hooks_comprehensive.rs +++ b/tests/install_hooks_comprehensive.rs @@ -7,6 +7,106 @@ use git_ai::commands::install_hooks::{ InstallResult, InstallStatus, run, run_uninstall, to_hashmap, }; use std::collections::HashMap; +use std::ffi::OsString; +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +struct EnvRestoreGuard { + prev_home: Option, + prev_userprofile: Option, + prev_path: Option, +} + +impl Drop for EnvRestoreGuard { + fn drop(&mut self) { + // SAFETY: tests using this guard are serialized via #[serial_test::serial], + // so mutating process env is safe. + unsafe { + match &self.prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.prev_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + match &self.prev_path { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + } +} + +fn install_fake_editor_cli(bin_dir: &Path, cli_name: &str) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let cli_path = bin_dir.join(cli_name); + let script = r#"#!/bin/sh +if [ "$1" = "--version" ]; then + echo "1.200.0" + exit 0 +fi +if [ "$1" = "--list-extensions" ]; then + echo "git-ai.git-ai-vscode" + exit 0 +fi +exit 0 +"#; + fs::write(&cli_path, script).expect("write fake cli"); + let mut perms = fs::metadata(&cli_path) + .expect("stat fake cli") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&cli_path, perms).expect("chmod fake cli"); + } + + #[cfg(windows)] + { + let cli_path = bin_dir.join(format!("{}.cmd", cli_name)); + let script = r#"@echo off +if "%1"=="--version" ( + echo 1.200.0 + exit /b 0 +) +if "%1"=="--list-extensions" ( + echo git-ai.git-ai-vscode + exit /b 0 +) +exit /b 0 +"#; + fs::write(&cli_path, script).expect("write fake cli"); + } +} + +fn with_temp_home(f: F) { + let temp_dir = TempDir::new().expect("temp home"); + let home = temp_dir.path().to_path_buf(); + let bin_dir = home.join("bin"); + fs::create_dir_all(&bin_dir).expect("temp bin"); + install_fake_editor_cli(&bin_dir, "code"); + install_fake_editor_cli(&bin_dir, "cursor"); + + let _restore_guard = EnvRestoreGuard { + prev_home: std::env::var_os("HOME"), + prev_userprofile: std::env::var_os("USERPROFILE"), + prev_path: std::env::var_os("PATH"), + }; + + // SAFETY: tests using this helper are serialized via #[serial_test::serial], + // so mutating process env is safe. + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); + // Keep PATH constrained to temp fake CLIs to avoid real tool discovery. + std::env::set_var("PATH", &bin_dir); + } + + f(&home); +} // ============================================================================== // InstallStatus Tests @@ -219,139 +319,124 @@ fn test_to_hashmap_all_statuses() { // ============================================================================== #[test] +#[serial_test::serial] fn test_run_install_hooks_no_args() { - // This will try to run against the actual system, but should not crash - // It may fail if binary path cannot be determined, which is acceptable - let result = run(&[]); - - // We just ensure it returns a result (success or error) - // The actual behavior depends on the system state - match result { - Ok(_statuses) => { - // Should return a HashMap, possibly empty - // Success is valid - } - Err(e) => { - // May fail if binary path is not available or other system issues - let err_msg = e.to_string(); - // Just ensure we get a meaningful error - assert!(!err_msg.is_empty()); + with_temp_home(|_| { + let result = run(&[]); + + match result { + Ok(_statuses) => {} + Err(e) => { + let err_msg = e.to_string(); + assert!(!err_msg.is_empty()); + } } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_with_dry_run_flag() { - let args = vec!["--dry-run".to_string()]; - let result = run(&args); - - // Dry run should not modify anything - match result { - Ok(_statuses) => { - // Success is valid + with_temp_home(|_| { + let args = vec!["--dry-run".to_string()]; + let result = run(&args); + + match result { + Ok(_statuses) => {} + Err(e) => { + let err_msg = e.to_string(); + assert!(!err_msg.is_empty()); + } } - Err(e) => { - let err_msg = e.to_string(); - assert!(!err_msg.is_empty()); - } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_with_dry_run_true() { - let args = vec!["--dry-run=true".to_string()]; - let result = run(&args); + with_temp_home(|_| { + let args = vec!["--dry-run=true".to_string()]; + let result = run(&args); - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_with_verbose_flag() { - let args = vec!["--verbose".to_string()]; - let result = run(&args); + with_temp_home(|_| { + let args = vec!["--verbose".to_string()]; + let result = run(&args); - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_with_verbose_short_flag() { - let args = vec!["-v".to_string()]; - let result = run(&args); + with_temp_home(|_| { + let args = vec!["-v".to_string()]; + let result = run(&args); - match result { - Ok(_statuses) => { - // Success is valid + match result { + Ok(_statuses) => {} + Err(_e) => {} } - Err(_e) => { - // May fail on CI or systems without binary path - } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_with_multiple_flags() { - let args = vec!["--dry-run".to_string(), "--verbose".to_string()]; - let result = run(&args); + with_temp_home(|_| { + let args = vec!["--dry-run".to_string(), "--verbose".to_string()]; + let result = run(&args); - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_with_dry_run_false() { - // Note: This could actually install hooks on the system - // In a real test environment, this should be run in isolation - let args = vec!["--dry-run=false".to_string()]; - let result = run(&args); - - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + with_temp_home(|_| { + let args = vec!["--dry-run=false".to_string()]; + let result = run(&args); + + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } #[test] +#[serial_test::serial] fn test_run_install_hooks_ignores_unknown_args() { - // Unknown arguments should be ignored - let args = vec![ - "--unknown-flag".to_string(), - "random-arg".to_string(), - "--dry-run".to_string(), - ]; - let result = run(&args); - - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + with_temp_home(|_| { + let args = vec![ + "--unknown-flag".to_string(), + "random-arg".to_string(), + "--dry-run".to_string(), + ]; + let result = run(&args); + + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } // ============================================================================== @@ -359,67 +444,65 @@ fn test_run_install_hooks_ignores_unknown_args() { // ============================================================================== #[test] +#[serial_test::serial] fn test_run_uninstall_hooks_no_args() { - let result = run_uninstall(&[]); - - match result { - Ok(_statuses) => { - // Success is valid + with_temp_home(|_| { + let result = run_uninstall(&[]); + + match result { + Ok(_statuses) => {} + Err(e) => { + let err_msg = e.to_string(); + assert!(!err_msg.is_empty()); + } } - Err(e) => { - let err_msg = e.to_string(); - assert!(!err_msg.is_empty()); - } - } + }); } #[test] +#[serial_test::serial] fn test_run_uninstall_hooks_with_dry_run() { - let args = vec!["--dry-run".to_string()]; - let result = run_uninstall(&args); + with_temp_home(|_| { + let args = vec!["--dry-run".to_string()]; + let result = run_uninstall(&args); - match result { - Ok(_statuses) => { - // Success is valid + match result { + Ok(_statuses) => {} + Err(_e) => {} } - Err(_e) => { - // May fail on CI or systems without binary path - } - } + }); } #[test] +#[serial_test::serial] fn test_run_uninstall_hooks_with_verbose() { - let args = vec!["--verbose".to_string()]; - let result = run_uninstall(&args); + with_temp_home(|_| { + let args = vec!["--verbose".to_string()]; + let result = run_uninstall(&args); - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } #[test] +#[serial_test::serial] fn test_run_uninstall_hooks_with_multiple_flags() { - let args = vec![ - "--dry-run=true".to_string(), - "-v".to_string(), - "--unknown".to_string(), - ]; - let result = run_uninstall(&args); - - match result { - Ok(_statuses) => { - // Success is valid - } - Err(_e) => { - // May fail on CI or systems without binary path + with_temp_home(|_| { + let args = vec![ + "--dry-run=true".to_string(), + "-v".to_string(), + "--unknown".to_string(), + ]; + let result = run_uninstall(&args); + + match result { + Ok(_statuses) => {} + Err(_e) => {} } - } + }); } // ============================================================================== @@ -527,48 +610,37 @@ fn test_install_result_message_for_metrics_warnings_join_with_semicolon() { // ============================================================================== #[test] +#[serial_test::serial] fn test_install_workflow_dry_run_does_not_modify_system() { - // Dry run should be safe to run repeatedly - let args = vec!["--dry-run".to_string(), "--verbose".to_string()]; - - let result1 = run(&args); - let result2 = run(&args); - - // Both runs should succeed or fail consistently - match (result1, result2) { - (Ok(_statuses1), Ok(_statuses2)) => { - // Results may differ if system state changes between runs, - // but both should be valid HashMaps - // Success is valid - } - (Err(_), Err(_)) => { - // Both failing is acceptable (e.g., on CI without proper setup) - } - _ => { - // Inconsistent results would indicate a problem, but we allow it - // since the system state could change + with_temp_home(|_| { + let args = vec!["--dry-run".to_string(), "--verbose".to_string()]; + + let result1 = run(&args); + let result2 = run(&args); + + match (result1, result2) { + (Ok(_statuses1), Ok(_statuses2)) => {} + (Err(_), Err(_)) => {} + _ => {} } - } + }); } #[test] +#[serial_test::serial] fn test_uninstall_workflow_dry_run_does_not_modify_system() { - let args = vec!["--dry-run".to_string()]; + with_temp_home(|_| { + let args = vec!["--dry-run".to_string()]; - let result1 = run_uninstall(&args); - let result2 = run_uninstall(&args); + let result1 = run_uninstall(&args); + let result2 = run_uninstall(&args); - match (result1, result2) { - (Ok(_statuses1), Ok(_statuses2)) => { - // Success is valid + match (result1, result2) { + (Ok(_statuses1), Ok(_statuses2)) => {} + (Err(_), Err(_)) => {} + _ => {} } - (Err(_), Err(_)) => { - // Both failing is acceptable - } - _ => { - // Allow inconsistent results due to system state changes - } - } + }); } // ============================================================================== diff --git a/tests/opencode.rs b/tests/opencode.rs index 0363319aa..429e70555 100644 --- a/tests/opencode.rs +++ b/tests/opencode.rs @@ -328,6 +328,44 @@ fn test_opencode_preset_posttooluse_returns_ai_checkpoint() { ); } +#[test] +fn test_opencode_preset_session_created_is_telemetry_only() { + let hook_input = json!({ + "hook_event_name": "session.created", + "hook_source": "opencode_plugin", + "session_id": "test-session-123", + "cwd": "/Users/test/project", + "telemetry_payload": { + "source": "opencode" + } + }) + .to_string(); + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input), + }; + + let result = OpenCodePreset + .run(flags) + .expect("Failed to run OpenCodePreset for session.created"); + + assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); + assert!( + result.transcript.is_none(), + "Telemetry-only events should skip transcript parsing" + ); + assert_eq!(result.hook_event_name.as_deref(), Some("session.created")); + assert_eq!(result.hook_source.as_deref(), Some("opencode_plugin")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + #[test] #[serial_test::serial] // Run serially to avoid env var conflicts with other tests fn test_opencode_preset_stores_session_id_in_metadata() { From 84bd0c761c2f7d0318fc5468afe236fc2dbb285b Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 24 Feb 2026 14:43:07 -0500 Subject: [PATCH 02/11] Fix OpenCode plugin type-check for duration metadata --- agent-support/opencode/git-ai.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/agent-support/opencode/git-ai.ts b/agent-support/opencode/git-ai.ts index 7355991bd..e5b8e5820 100644 --- a/agent-support/opencode/git-ai.ts +++ b/agent-support/opencode/git-ai.ts @@ -232,8 +232,9 @@ export const GitAiPlugin: Plugin = async (ctx) => { tool_name: toolName, tool_use_id: String(input?.callID ?? ""), } - if (typeof output?.duration === "number") { - telemetryPayload.duration_ms = String(Math.max(0, Math.floor(output.duration))) + const durationMs = output?.metadata?.duration_ms ?? output?.metadata?.duration + if (typeof durationMs === "number") { + telemetryPayload.duration_ms = String(Math.max(0, Math.floor(durationMs))) } await emitCheckpoint({ From c74c2c3895781e98d7f976ddd96d1559b244a689 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 24 Feb 2026 14:45:49 -0500 Subject: [PATCH 03/11] Refactor response phase type to satisfy clippy lint --- src/commands/checkpoint.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index 601d18dfc..f3917fb68 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -216,10 +216,10 @@ fn mcp_phase_from_hook( } } -fn response_phases_from_hook( - hook: &str, - result: &AgentRunResult, -) -> (Option<(&'static str, u32)>, Option<(&'static str, u32)>) { +type ResponsePhase = (&'static str, u32); +type ResponsePhases = (Option, Option); + +fn response_phases_from_hook(hook: &str, result: &AgentRunResult) -> ResponsePhases { if hook == "message.part.updated" { let is_human_role = payload_str(result, "role").is_some_and(is_human_like_role); if is_human_role || payload_u32(result, "response_char_count").is_none() { From 26cc96386b4733162d90afe38e62faeb0ec94357 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 24 Feb 2026 14:50:34 -0500 Subject: [PATCH 04/11] Rename inferred telemetry builder to satisfy clippy --- src/commands/checkpoint.rs | 16 ++++++++-------- src/metrics/events.rs | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index f3917fb68..2d4f683c3 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -366,7 +366,7 @@ pub(crate) fn emit_agent_hook_telemetry( .unwrap_or("unknown"), ) .mode(payload_str(result, "mode").unwrap_or("unknown")) - .is_inferred(0); + .inferred(0); crate::metrics::record(values, attrs.clone()); } else if matches!(hook, "SessionEnd" | "sessionEnd" | "session.deleted") { let mut values = crate::metrics::AgentSessionValues::new() @@ -378,7 +378,7 @@ pub(crate) fn emit_agent_hook_telemetry( .unwrap_or("unknown"), ) .mode(payload_str(result, "mode").unwrap_or("unknown")) - .is_inferred(0); + .inferred(0); if let Some(duration_ms) = payload_u64(result, "duration_ms") { values = values.duration_ms(duration_ms); } @@ -393,7 +393,7 @@ pub(crate) fn emit_agent_hook_telemetry( .phase("started") .source("inferred") .mode("agent") - .is_inferred(1); + .inferred(1); crate::metrics::record(values, attrs.clone()); } } @@ -426,7 +426,7 @@ pub(crate) fn emit_agent_hook_telemetry( values = values.failure_type(failure_type.to_string()); } if payload_is_true(result, "inferred_tool") { - values = values.is_inferred(1); + values = values.inferred(1); } crate::metrics::record(values, attrs.clone()); } @@ -450,7 +450,7 @@ pub(crate) fn emit_agent_hook_telemetry( values = values.failure_type(failure_type.to_string()); } if !matches!(hook, "beforeMCPExecution" | "afterMCPExecution") { - values = values.is_inferred(1); + values = values.inferred(1); } crate::metrics::record(values, attrs.clone()); } @@ -466,7 +466,7 @@ pub(crate) fn emit_agent_hook_telemetry( payload_str(result, "skill_detection_method"), Some("explicit") ) { - values = values.is_inferred(1); + values = values.inferred(1); } crate::metrics::record(values, attrs.clone()); } @@ -509,7 +509,7 @@ pub(crate) fn emit_agent_hook_telemetry( { let mut values = crate::metrics::AgentResponseValues::new() .phase(phase) - .is_inferred(inferred); + .inferred(inferred); if let Some(reason) = payload_str(result, "reason") { values = values.reason(reason); } @@ -528,7 +528,7 @@ pub(crate) fn emit_agent_hook_telemetry( { let mut values = crate::metrics::AgentResponseValues::new() .phase(phase) - .is_inferred(inferred); + .inferred(inferred); if let Some(status) = payload_str(result, "status") { values = values.status(status); } diff --git a/src/metrics/events.rs b/src/metrics/events.rs index d330f31bd..7df118f23 100644 --- a/src/metrics/events.rs +++ b/src/metrics/events.rs @@ -716,7 +716,7 @@ impl AgentSessionValues { } #[allow(dead_code)] - pub fn is_inferred(mut self, value: u32) -> Self { + pub fn inferred(mut self, value: u32) -> Self { self.is_inferred = Some(Some(value)); self } @@ -906,7 +906,7 @@ impl AgentResponseValues { self } - pub fn is_inferred(mut self, value: u32) -> Self { + pub fn inferred(mut self, value: u32) -> Self { self.is_inferred = Some(Some(value)); self } @@ -1018,7 +1018,7 @@ impl AgentToolCallValues { self } - pub fn is_inferred(mut self, value: u32) -> Self { + pub fn inferred(mut self, value: u32) -> Self { self.is_inferred = Some(Some(value)); self } @@ -1143,7 +1143,7 @@ impl AgentMcpCallValues { self } - pub fn is_inferred(mut self, value: u32) -> Self { + pub fn inferred(mut self, value: u32) -> Self { self.is_inferred = Some(Some(value)); self } @@ -1246,7 +1246,7 @@ impl AgentSkillUsageValues { self } - pub fn is_inferred(mut self, value: u32) -> Self { + pub fn inferred(mut self, value: u32) -> Self { self.is_inferred = Some(Some(value)); self } @@ -1354,7 +1354,7 @@ impl AgentSubagentValues { } #[allow(dead_code)] - pub fn is_inferred(mut self, value: u32) -> Self { + pub fn inferred(mut self, value: u32) -> Self { self.is_inferred = Some(Some(value)); self } @@ -1806,7 +1806,7 @@ mod tests { .phase("started") .source("interactive") .mode("agent") - .is_inferred(0); + .inferred(0); let sparse = PosEncoded::to_sparse(&values); let restored = ::from_sparse(&sparse); @@ -1842,7 +1842,7 @@ mod tests { .phase("ended") .status("completed") .response_char_count(300) - .is_inferred(1); + .inferred(1); let sparse = PosEncoded::to_sparse(&values); let restored = ::from_sparse(&sparse); @@ -1902,7 +1902,7 @@ mod tests { let values = AgentSkillUsageValues::new() .skill_name("security-review") .detection_method("inferred_tool") - .is_inferred(1); + .inferred(1); let sparse = PosEncoded::to_sparse(&values); let restored = ::from_sparse(&sparse); From 718023b062022a37fa5c7d0a70926a7486f55467 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Tue, 24 Feb 2026 20:10:49 -0500 Subject: [PATCH 05/11] Migrate telemetry dedupe to filesystem buckets --- src/commands/checkpoint.rs | 282 ++++++++-- .../checkpoint_agent/agent_presets.rs | 1 + src/commands/flush_metrics_db.rs | 1 + src/mdm/agents/cursor.rs | 8 + src/metrics/db.rs | 202 +------ src/metrics/dedupe_fs.rs | 526 ++++++++++++++++++ src/metrics/events.rs | 1 + src/metrics/mod.rs | 72 +++ src/observability/mod.rs | 5 +- tests/cursor.rs | 72 +++ 10 files changed, 912 insertions(+), 258 deletions(-) create mode 100644 src/metrics/dedupe_fs.rs diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index 2d4f683c3..5f0adde0d 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -87,7 +87,6 @@ fn build_checkpoint_attrs( } /// Persistent local rate limit keyed by prompt ID hash. -#[cfg(not(any(test, feature = "test-support")))] pub(crate) fn should_emit_agent_usage(agent_id: &AgentId) -> bool { let prompt_id = generate_short_hash(&agent_id.id, &agent_id.tool); let now_ts = SystemTime::now() @@ -95,40 +94,21 @@ pub(crate) fn should_emit_agent_usage(agent_id: &AgentId) -> bool { .unwrap_or_default() .as_secs(); - let Ok(db) = crate::metrics::db::MetricsDatabase::global() else { - return true; - }; - let Ok(mut db_lock) = db.lock() else { - return true; - }; - - db_lock - .should_emit_agent_usage(&prompt_id, now_ts, AGENT_USAGE_MIN_INTERVAL_SECS) - .unwrap_or(true) + crate::metrics::dedupe_fs::should_emit( + "agent_usage", + &prompt_id, + now_ts, + AGENT_USAGE_MIN_INTERVAL_SECS, + ) } -/// Always returns false in test mode — no metrics DB access needed. -#[cfg(any(test, feature = "test-support"))] -pub(crate) fn should_emit_agent_usage(_agent_id: &AgentId) -> bool { - false -} - -#[cfg(not(any(test, feature = "test-support")))] -fn should_emit_telemetry_once(event_key: &str, now_ts: u64, ttl_secs: u64) -> bool { - let Ok(db) = crate::metrics::db::MetricsDatabase::global() else { - return true; - }; - let Ok(mut db_lock) = db.lock() else { - return true; - }; - db_lock - .should_emit_once(event_key, now_ts, ttl_secs) - .unwrap_or(true) -} - -#[cfg(any(test, feature = "test-support"))] -fn should_emit_telemetry_once(_event_key: &str, _now_ts: u64, _ttl_secs: u64) -> bool { - true +fn should_emit_telemetry_once( + namespace: &str, + event_key: &str, + now_ts: u64, + ttl_secs: u64, +) -> bool { + crate::metrics::dedupe_fs::should_emit(namespace, event_key, now_ts, ttl_secs) } fn payload_str<'a>(result: &'a AgentRunResult, key: &str) -> Option<&'a str> { @@ -368,27 +348,17 @@ pub(crate) fn emit_agent_hook_telemetry( .mode(payload_str(result, "mode").unwrap_or("unknown")) .inferred(0); crate::metrics::record(values, attrs.clone()); - } else if matches!(hook, "SessionEnd" | "sessionEnd" | "session.deleted") { - let mut values = crate::metrics::AgentSessionValues::new() - .phase("ended") - .reason(payload_str(result, "reason").unwrap_or("completed")) - .source( - payload_str(result, "source") - .or(result.hook_source.as_deref()) - .unwrap_or("unknown"), - ) - .mode(payload_str(result, "mode").unwrap_or("unknown")) - .inferred(0); - if let Some(duration_ms) = payload_u64(result, "duration_ms") { - values = values.duration_ms(duration_ms); - } - crate::metrics::record(values, attrs.clone()); } else if result.agent_id.tool == "codex" && is_transcript_inferred_source(result) { let dedupe_key = format!( "session-start:{}:{}", result.agent_id.tool, result.agent_id.id ); - if should_emit_telemetry_once(&dedupe_key, now_ts, SESSION_DEDUPE_TTL_SECS) { + if should_emit_telemetry_once( + "session_start", + &dedupe_key, + now_ts, + SESSION_DEDUPE_TTL_SECS, + ) { let values = crate::metrics::AgentSessionValues::new() .phase("started") .source("inferred") @@ -503,10 +473,12 @@ pub(crate) fn emit_agent_hook_telemetry( "response-start:{}:{}:{}", result.agent_id.tool, result.agent_id.id, &dedupe_generation ); - let should_dedupe = inferred == 1 && is_transcript_inferred_source(result); - if !should_dedupe - || should_emit_telemetry_once(&dedupe_key, now_ts, RESPONSE_DEDUPE_TTL_SECS) - { + if should_emit_telemetry_once( + "response_start", + &dedupe_key, + now_ts, + RESPONSE_DEDUPE_TTL_SECS, + ) { let mut values = crate::metrics::AgentResponseValues::new() .phase(phase) .inferred(inferred); @@ -522,10 +494,12 @@ pub(crate) fn emit_agent_hook_telemetry( "response-end:{}:{}:{}", result.agent_id.tool, result.agent_id.id, &dedupe_generation ); - let should_dedupe = inferred == 1 && is_transcript_inferred_source(result); - if !should_dedupe - || should_emit_telemetry_once(&dedupe_key, now_ts, RESPONSE_DEDUPE_TTL_SECS) - { + if should_emit_telemetry_once( + "response_end", + &dedupe_key, + now_ts, + RESPONSE_DEDUPE_TTL_SECS, + ) { let mut values = crate::metrics::AgentResponseValues::new() .phase(phase) .inferred(inferred); @@ -1926,6 +1900,9 @@ fn upsert_checkpoint_prompt_to_db( mod tests { use super::*; use crate::git::test_utils::TmpRepo; + use serial_test::serial; + use std::ffi::OsString; + use tempfile::TempDir; #[test] fn test_checkpoint_with_staged_changes() { @@ -2578,6 +2555,49 @@ mod tests { } } + struct EnvRestoreGuard { + previous: Option, + } + + impl Drop for EnvRestoreGuard { + fn drop(&mut self) { + // SAFETY: tests that mutate env are marked #[serial]. + unsafe { + match &self.previous { + Some(value) => std::env::set_var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR", value), + None => std::env::remove_var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR"), + } + } + } + } + + fn with_temp_dedupe_dir(f: F) { + let temp = TempDir::new().unwrap(); + let dedupe_dir = temp.path().join("telemetry-dedupe"); + crate::metrics::dedupe_fs::reset_for_tests(); + + let _restore_guard = EnvRestoreGuard { + previous: std::env::var_os("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR"), + }; + + // SAFETY: tests that mutate env are marked #[serial]. + unsafe { + std::env::set_var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR", &dedupe_dir); + } + + f(); + crate::metrics::dedupe_fs::reset_for_tests(); + } + + fn capture_hook_metrics(result: &AgentRunResult) -> Vec { + crate::metrics::test_start_metric_capture(); + emit_agent_hook_telemetry( + Some(result), + crate::metrics::EventAttributes::with_version("1.0.0"), + ); + crate::metrics::test_take_captured_metrics() + } + #[test] fn test_tool_phase_from_hook_supports_legacy_copilot_events() { assert_eq!(tool_phase_from_hook("before_edit"), Some("started")); @@ -2687,4 +2707,152 @@ mod tests { ); assert_eq!(attrs.tool, Some(Some("github-copilot".to_string()))); } + + #[test] + #[serial] + fn test_emit_agent_hook_telemetry_emits_session_start_only() { + with_temp_dedupe_dir(|| { + let mut start = + test_agent_run_result_for_telemetry(&[("source", "new"), ("mode", "agent")]); + start.agent_id.tool = "claude".to_string(); + start.hook_source = Some("claude_hook".to_string()); + start.hook_event_name = Some("SessionStart".to_string()); + + let start_events = capture_hook_metrics(&start); + assert!(start_events.iter().any(|event| event.event_id == 5)); + let session_event = start_events + .iter() + .find(|event| event.event_id == 5) + .unwrap(); + assert_eq!( + session_event.values.get("0").and_then(|v| v.as_str()), + Some("started") + ); + + let mut end = start.clone(); + end.hook_event_name = Some("SessionEnd".to_string()); + let end_events = capture_hook_metrics(&end); + assert!( + !end_events.iter().any(|event| event.event_id == 5), + "SessionEnd should not emit agent_session metrics" + ); + }); + } + + #[test] + #[serial] + fn test_emit_agent_hook_telemetry_dedupes_response_updates() { + with_temp_dedupe_dir(|| { + let mut result = test_agent_run_result_for_telemetry(&[ + ("role", "assistant"), + ("message_id", "msg-123"), + ("response_char_count", "12"), + ]); + result.agent_id.tool = "opencode".to_string(); + result.agent_id.id = "session-123".to_string(); + result.hook_source = Some("opencode_plugin".to_string()); + result.hook_event_name = Some("message.part.updated".to_string()); + + let first = capture_hook_metrics(&result); + let first_count = first.iter().filter(|event| event.event_id == 7).count(); + assert_eq!( + first_count, 1, + "first update should emit one response event" + ); + + let second = capture_hook_metrics(&result); + let second_count = second.iter().filter(|event| event.event_id == 7).count(); + assert_eq!( + second_count, 0, + "second update with same generation key should be deduped" + ); + }); + } + + #[test] + #[serial] + fn test_emit_agent_hook_telemetry_maps_tool_mcp_subagent_and_skill() { + with_temp_dedupe_dir(|| { + let mut result = test_agent_run_result_for_telemetry(&[ + ("tool_name", "mcp__fs__read"), + ("tool_use_id", "tool-123"), + ("mcp_server", "fs"), + ("mcp_transport", "stdio"), + ("subagent_id", "subagent-1"), + ("subagent_type", "Plan"), + ("status", "completed"), + ("skill_name", "checks"), + ("skill_detection_method", "inferred_prompt"), + ("duration_ms", "45"), + ]); + result.agent_id.tool = "cursor".to_string(); + result.agent_id.id = "cursor-session".to_string(); + result.hook_source = Some("cursor_hook".to_string()); + result.hook_event_name = Some("preToolUse".to_string()); + + let events = capture_hook_metrics(&result); + + assert!(events.iter().any(|event| event.event_id == 8)); + assert!(events.iter().any(|event| event.event_id == 9)); + assert!(events.iter().any(|event| event.event_id == 10)); + assert!( + !events.iter().any(|event| event.event_id == 11), + "subagent metrics should only come from subagent hooks" + ); + + let tool = events.iter().find(|event| event.event_id == 8).unwrap(); + assert_eq!( + tool.values.get("0").and_then(|v| v.as_str()), + Some("started") + ); + assert_eq!( + tool.values.get("1").and_then(|v| v.as_str()), + Some("mcp__fs__read") + ); + + let mcp = events.iter().find(|event| event.event_id == 9).unwrap(); + assert_eq!( + mcp.values.get("0").and_then(|v| v.as_str()), + Some("started") + ); + assert_eq!(mcp.values.get("1").and_then(|v| v.as_str()), Some("fs")); + + let skill = events.iter().find(|event| event.event_id == 10).unwrap(); + assert_eq!( + skill.values.get("0").and_then(|v| v.as_str()), + Some("checks") + ); + + let mut subagent_result = result.clone(); + subagent_result.hook_event_name = Some("subagentStart".to_string()); + let subagent_events = capture_hook_metrics(&subagent_result); + let subagent = subagent_events + .iter() + .find(|event| event.event_id == 11) + .unwrap(); + assert_eq!( + subagent.values.get("0").and_then(|v| v.as_str()), + Some("started") + ); + assert_eq!( + subagent.values.get("1").and_then(|v| v.as_str()), + Some("subagent-1") + ); + }); + } + + #[test] + #[serial] + fn test_should_emit_agent_usage_uses_filesystem_dedupe() { + with_temp_dedupe_dir(|| { + let agent = AgentId { + tool: "cursor".to_string(), + id: "session-usage".to_string(), + model: "gpt-5".to_string(), + }; + + assert!(should_emit_agent_usage(&agent)); + assert!(!should_emit_agent_usage(&agent)); + }); + } } diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 98ee1c46a..ee78095c2 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -1545,6 +1545,7 @@ impl AgentCheckpointPreset for CursorPreset { "afterFileEdit", "afterAgentResponse", "afterAgentThought", + "preCompact", "stop", ]; if !supported_events.contains(&hook_event_name.as_str()) { diff --git a/src/commands/flush_metrics_db.rs b/src/commands/flush_metrics_db.rs index 2204abc80..058db947f 100644 --- a/src/commands/flush_metrics_db.rs +++ b/src/commands/flush_metrics_db.rs @@ -8,6 +8,7 @@ use crate::metrics::{MetricEvent, MetricsBatch}; /// Max events per batch upload const MAX_BATCH_SIZE: usize = 250; +#[cfg(not(any(test, feature = "test-support")))] const ENV_FLUSH_METRICS_DB_WORKER: &str = "GIT_AI_FLUSH_METRICS_DB_WORKER"; /// Spawn a background process to flush metrics DB diff --git a/src/mdm/agents/cursor.rs b/src/mdm/agents/cursor.rs index aab6187e9..65b1655a2 100644 --- a/src/mdm/agents/cursor.rs +++ b/src/mdm/agents/cursor.rs @@ -29,9 +29,11 @@ const CURSOR_HOOK_EVENTS: &[&str] = &[ "afterShellExecution", "beforeMCPExecution", "afterMCPExecution", + "beforeReadFile", "afterFileEdit", "afterAgentResponse", "afterAgentThought", + "preCompact", "stop", ]; @@ -675,4 +677,10 @@ mod tests { let targets = CursorInstaller::settings_targets(); assert!(!targets.is_empty()); } + + #[test] + fn test_cursor_hook_event_list_includes_precompact_and_before_read_file() { + assert!(CURSOR_HOOK_EVENTS.contains(&"preCompact")); + assert!(CURSOR_HOOK_EVENTS.contains(&"beforeReadFile")); + } } diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 0a7d06177..e856609d4 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -4,12 +4,12 @@ //! Server handles idempotency - no retry/queue logic needed. use crate::error::GitAiError; -use rusqlite::{Connection, OptionalExtension, params}; +use rusqlite::{Connection, params}; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must match MIGRATIONS.len()) -const SCHEMA_VERSION: usize = 3; +const SCHEMA_VERSION: usize = 2; /// Database migrations - each migration upgrades the schema by one version const MIGRATIONS: &[&str] = &[ @@ -27,13 +27,6 @@ const MIGRATIONS: &[&str] = &[ last_sent_ts INTEGER NOT NULL ); "#, - // Migration 2 -> 3: Generic dedupe keys for inferred hook telemetry - r#" - CREATE TABLE event_dedupe ( - event_key TEXT PRIMARY KEY, - last_emitted_ts INTEGER NOT NULL - ); - "#, ]; /// Global database singleton @@ -267,91 +260,6 @@ impl MetricsDatabase { .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0))?; Ok(count as usize) } - - /// Returns whether an `agent_usage` event should be emitted for this prompt_id. - /// - /// If emitted, this method also updates the prompt's last-sent timestamp. - #[allow(dead_code)] - pub fn should_emit_agent_usage( - &mut self, - prompt_id: &str, - now_ts: u64, - min_interval_secs: u64, - ) -> Result { - if prompt_id.is_empty() { - return Ok(true); - } - - let tx = self.conn.transaction()?; - let existing_ts: Option = tx - .query_row( - "SELECT last_sent_ts FROM agent_usage_throttle WHERE prompt_id = ?1", - params![prompt_id], - |row| row.get(0), - ) - .optional()?; - - let should_emit = existing_ts - .map(|prev_ts| now_ts.saturating_sub(prev_ts as u64) >= min_interval_secs) - .unwrap_or(true); - - if should_emit { - tx.execute( - r#" - INSERT INTO agent_usage_throttle (prompt_id, last_sent_ts) - VALUES (?1, ?2) - ON CONFLICT(prompt_id) DO UPDATE SET last_sent_ts = excluded.last_sent_ts - "#, - params![prompt_id, now_ts as i64], - )?; - } - - tx.commit()?; - Ok(should_emit) - } - - /// Generic dedupe utility for inferred telemetry events. - /// - /// Returns true when the event should be emitted (and stores `now_ts`), - /// false when the event is still inside the dedupe window. - #[allow(dead_code)] - pub fn should_emit_once( - &mut self, - event_key: &str, - now_ts: u64, - ttl_secs: u64, - ) -> Result { - if event_key.is_empty() { - return Ok(true); - } - - let tx = self.conn.transaction()?; - let existing_ts: Option = tx - .query_row( - "SELECT last_emitted_ts FROM event_dedupe WHERE event_key = ?1", - params![event_key], - |row| row.get(0), - ) - .optional()?; - - let should_emit = existing_ts - .map(|prev_ts| now_ts.saturating_sub(prev_ts as u64) >= ttl_secs) - .unwrap_or(true); - - if should_emit { - tx.execute( - r#" - INSERT INTO event_dedupe (event_key, last_emitted_ts) - VALUES (?1, ?2) - ON CONFLICT(event_key) DO UPDATE SET last_emitted_ts = excluded.last_emitted_ts - "#, - params![event_key, now_ts as i64], - )?; - } - - tx.commit()?; - Ok(should_emit) - } } #[cfg(test)] @@ -396,18 +304,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "3"); - - // Verify event_dedupe table exists in schema v3 - let dedupe_count: i64 = db - .conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='event_dedupe'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(dedupe_count, 1); + assert_eq!(version, "2"); } #[test] @@ -501,97 +398,4 @@ mod tests { assert!(path.to_string_lossy().contains("internal")); assert!(path.to_string_lossy().ends_with("metrics-db")); } - - #[test] - fn test_should_emit_agent_usage_rate_limit() { - let (mut db, _temp_dir) = create_test_db(); - let prompt_id = "prompt-123"; - - // First event for a prompt should be allowed. - assert!( - db.should_emit_agent_usage(prompt_id, 1_700_000_000, 300) - .unwrap() - ); - // Subsequent event inside the window should be throttled. - assert!( - !db.should_emit_agent_usage(prompt_id, 1_700_000_120, 300) - .unwrap() - ); - // Event outside the window should be allowed again. - assert!( - db.should_emit_agent_usage(prompt_id, 1_700_000_301, 300) - .unwrap() - ); - } - - #[test] - fn test_should_emit_once_dedupe() { - let (mut db, _temp_dir) = create_test_db(); - let event_key = "cursor:thread-1:response-started"; - - assert!( - db.should_emit_once(event_key, 1_700_100_000, 86_400) - .unwrap() - ); - assert!( - !db.should_emit_once(event_key, 1_700_100_100, 86_400) - .unwrap() - ); - assert!( - db.should_emit_once(event_key, 1_700_186_401, 86_400) - .unwrap() - ); - } - - #[test] - fn test_should_emit_once_empty_key_always_true() { - let (mut db, _temp_dir) = create_test_db(); - assert!(db.should_emit_once("", 1_700_100_000, 10).unwrap()); - assert!(db.should_emit_once("", 1_700_100_001, 10).unwrap()); - } - - #[test] - fn test_migration_from_v2_to_v3_adds_event_dedupe() { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir.path().join("legacy-v2.db"); - let conn = Connection::open(&db_path).unwrap(); - conn.execute_batch( - r#" - CREATE TABLE schema_metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); - INSERT INTO schema_metadata (key, value) VALUES ('version', '2'); - CREATE TABLE metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_json TEXT NOT NULL - ); - CREATE TABLE agent_usage_throttle ( - prompt_id TEXT PRIMARY KEY, - last_sent_ts INTEGER NOT NULL - ); - "#, - ) - .unwrap(); - - let mut db = MetricsDatabase { conn }; - db.initialize_schema().unwrap(); - - let version: String = db - .conn - .query_row( - "SELECT value FROM schema_metadata WHERE key = 'version'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(version, "3"); - - let dedupe_count: i64 = db - .conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='event_dedupe'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(dedupe_count, 1); - } } diff --git a/src/metrics/dedupe_fs.rs b/src/metrics/dedupe_fs.rs new file mode 100644 index 000000000..52fc06eaf --- /dev/null +++ b/src/metrics/dedupe_fs.rs @@ -0,0 +1,526 @@ +//! Best-effort filesystem dedupe for local telemetry throttling. + +use crate::error::GitAiError; +use chrono::{Duration, NaiveDate, TimeZone, Utc}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; + +const RETENTION_DAYS: i64 = 5; +const CLEANUP_INTERVAL_SECS: u64 = 60 * 60 * 6; +const CACHE_MAX_ENTRIES: usize = 10_000; + +static LAST_CLEANUP_TS: AtomicU64 = AtomicU64::new(0); +static DEDUPE_CACHE: OnceLock>> = OnceLock::new(); + +fn dedupe_cache() -> &'static Mutex> { + DEDUPE_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn key_hash(input: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn cache_key(namespace: &str, hash: &str) -> String { + format!("{namespace}:{hash}") +} + +fn utc_day_from_ts(ts: u64) -> NaiveDate { + Utc.timestamp_opt(ts as i64, 0) + .single() + .map(|dt| dt.date_naive()) + .unwrap_or_else(|| Utc::now().date_naive()) +} + +fn marker_path(base_dir: &Path, namespace: &str, day: &str, hash: &str) -> PathBuf { + let fanout = &hash[..2]; + base_dir + .join(namespace) + .join(day) + .join(fanout) + .join(format!("{hash}.ts")) +} + +fn day_buckets(now_ts: u64, ttl_secs: u64) -> Vec { + let now_day = utc_day_from_ts(now_ts); + let prior_days = ttl_secs.div_ceil(86_400).saturating_add(1); + + (0..=prior_days) + .map(|offset| { + (now_day - Duration::days(offset as i64)) + .format("%Y-%m-%d") + .to_string() + }) + .collect() +} + +fn write_marker(path: &Path, ts: u64) -> Result<(), GitAiError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let tmp_path = path.with_extension(format!( + "{}.{}.tmp", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + + std::fs::write(&tmp_path, ts.to_string())?; + if let Err(err) = replace_file_atomic(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err.into()); + } + + Ok(()) +} + +#[cfg(windows)] +fn replace_file_atomic(from: &Path, to: &Path) -> std::io::Result<()> { + match std::fs::rename(from, to) { + Ok(()) => Ok(()), + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied + ) && to.exists() => + { + let _ = std::fs::remove_file(to); + std::fs::rename(from, to).map_err(|rename_err| { + std::io::Error::new( + rename_err.kind(), + format!( + "failed to replace existing marker after initial rename error ({err}): {rename_err}" + ), + ) + }) + } + Err(err) => Err(err), + } +} + +#[cfg(not(windows))] +fn replace_file_atomic(from: &Path, to: &Path) -> std::io::Result<()> { + std::fs::rename(from, to) +} + +fn put_cache(namespace: &str, hash: &str, ts: u64) { + let Ok(mut cache) = dedupe_cache().lock() else { + return; + }; + + if cache.len() >= CACHE_MAX_ENTRIES { + let remove_count = CACHE_MAX_ENTRIES / 4; + let keys_to_remove: Vec = cache.keys().take(remove_count).cloned().collect(); + for key in keys_to_remove { + cache.remove(&key); + } + } + + cache.insert(cache_key(namespace, hash), ts); +} + +fn get_cache(namespace: &str, hash: &str) -> Option { + let Ok(cache) = dedupe_cache().lock() else { + return None; + }; + cache.get(&cache_key(namespace, hash)).copied() +} + +fn dedupe_base_dir() -> Result { + #[cfg(any(test, feature = "test-support"))] + if let Ok(path) = std::env::var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR") { + return Ok(PathBuf::from(path)); + } + + #[cfg(any(test, feature = "test-support"))] + { + Ok(std::env::temp_dir().join(format!("git-ai-telemetry-dedupe-{}", std::process::id()))) + } + + #[cfg(not(any(test, feature = "test-support")))] + { + let home = dirs::home_dir().ok_or_else(|| { + GitAiError::Generic("Could not determine home directory for dedupe storage".to_string()) + })?; + Ok(home + .join(".git-ai") + .join("internal") + .join("telemetry-dedupe")) + } +} + +#[cfg(any(test, feature = "test-support"))] +#[allow(dead_code)] +pub(crate) fn reset_for_tests() { + LAST_CLEANUP_TS.store(0, Ordering::Relaxed); + if let Ok(mut cache) = dedupe_cache().lock() { + cache.clear(); + } +} + +pub(crate) fn maybe_cleanup(now_ts: u64) { + let previous = LAST_CLEANUP_TS.load(Ordering::Relaxed); + if previous > 0 && now_ts.saturating_sub(previous) < CLEANUP_INTERVAL_SECS { + return; + } + + if LAST_CLEANUP_TS + .compare_exchange(previous, now_ts, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let _ = cleanup_old_days(now_ts); + } +} + +pub(crate) fn cleanup_old_days(now_ts: u64) -> Result<(), GitAiError> { + let base_dir = dedupe_base_dir()?; + if !base_dir.exists() { + return Ok(()); + } + + let today = utc_day_from_ts(now_ts); + let keep_after = today - Duration::days(RETENTION_DAYS - 1); + + for namespace_entry in std::fs::read_dir(&base_dir)? { + let namespace_entry = namespace_entry?; + if !namespace_entry.file_type()?.is_dir() { + continue; + } + + for day_entry in std::fs::read_dir(namespace_entry.path())? { + let day_entry = day_entry?; + if !day_entry.file_type()?.is_dir() { + continue; + } + + let day_name = day_entry.file_name(); + let day_name = day_name.to_string_lossy(); + let Ok(day_date) = NaiveDate::parse_from_str(&day_name, "%Y-%m-%d") else { + continue; + }; + + if day_date < keep_after { + let _ = std::fs::remove_dir_all(day_entry.path()); + } + } + } + + Ok(()) +} + +pub(crate) fn should_emit(namespace: &str, key: &str, now_ts: u64, ttl_secs: u64) -> bool { + if namespace.trim().is_empty() || key.trim().is_empty() { + return true; + } + + maybe_cleanup(now_ts); + + let base_dir = match dedupe_base_dir() { + Ok(path) => path, + Err(_) => return true, + }; + + let hash = key_hash(key); + + if ttl_secs > 0 + && let Some(previous_ts) = get_cache(namespace, &hash) + && now_ts.saturating_sub(previous_ts) < ttl_secs + { + return false; + } + + let mut latest_ts: Option = None; + for day_bucket in day_buckets(now_ts, ttl_secs) { + let path = marker_path(&base_dir, namespace, &day_bucket, &hash); + if !path.exists() { + continue; + } + + let content = match std::fs::read_to_string(&path) { + Ok(value) => value, + Err(_) => { + let today = utc_day_from_ts(now_ts).format("%Y-%m-%d").to_string(); + let today_path = marker_path(&base_dir, namespace, &today, &hash); + let _ = write_marker(&today_path, now_ts); + put_cache(namespace, &hash, now_ts); + return true; + } + }; + + let ts = match content.trim().parse::() { + Ok(value) => value, + Err(_) => { + let today = utc_day_from_ts(now_ts).format("%Y-%m-%d").to_string(); + let today_path = marker_path(&base_dir, namespace, &today, &hash); + let _ = write_marker(&today_path, now_ts); + put_cache(namespace, &hash, now_ts); + return true; + } + }; + + latest_ts = Some(latest_ts.map_or(ts, |current| current.max(ts))); + } + + if ttl_secs > 0 + && let Some(previous_ts) = latest_ts + && now_ts.saturating_sub(previous_ts) < ttl_secs + { + put_cache(namespace, &hash, previous_ts); + return false; + } + + let today = utc_day_from_ts(now_ts).format("%Y-%m-%d").to_string(); + let today_path = marker_path(&base_dir, namespace, &today, &hash); + + if write_marker(&today_path, now_ts).is_err() { + return true; + } + + put_cache(namespace, &hash, now_ts); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::ffi::OsString; + use std::sync::{Arc, Barrier}; + use std::thread; + use tempfile::TempDir; + + struct EnvRestoreGuard { + previous: Option, + } + + impl Drop for EnvRestoreGuard { + fn drop(&mut self) { + // SAFETY: tests are marked #[serial], so process env mutation is safe. + unsafe { + match &self.previous { + Some(value) => std::env::set_var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR", value), + None => std::env::remove_var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR"), + } + } + } + } + + fn with_temp_dedupe_dir(f: F) { + let temp = TempDir::new().unwrap(); + let dedupe_dir = temp.path().join("telemetry-dedupe"); + reset_for_tests(); + + let _restore_guard = EnvRestoreGuard { + previous: std::env::var_os("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR"), + }; + + // SAFETY: tests are marked #[serial], so process env mutation is safe. + unsafe { + std::env::set_var("GIT_AI_TEST_TELEMETRY_DEDUPE_DIR", &dedupe_dir); + } + + f(&dedupe_dir); + reset_for_tests(); + } + + #[test] + #[serial] + fn test_should_emit_respects_ttl_window() { + with_temp_dedupe_dir(|_| { + let now = 1_700_000_000; + assert!(should_emit("response_start", "key-1", now, 60)); + assert!(!should_emit("response_start", "key-1", now + 10, 60)); + assert!(should_emit("response_start", "key-1", now + 61, 60)); + }); + } + + #[test] + #[serial] + fn test_should_emit_namespace_isolation() { + with_temp_dedupe_dir(|_| { + let now = 1_700_000_000; + assert!(should_emit("agent_usage", "same-key", now, 120)); + assert!(should_emit("response_start", "same-key", now + 1, 120)); + assert!(!should_emit("agent_usage", "same-key", now + 2, 120)); + assert!(!should_emit("response_start", "same-key", now + 3, 120)); + }); + } + + #[test] + #[serial] + fn test_should_emit_reads_previous_day_bucket_within_ttl() { + with_temp_dedupe_dir(|_| { + let first = Utc + .with_ymd_and_hms(2026, 2, 24, 23, 59, 50) + .single() + .unwrap() + .timestamp() as u64; + let second = Utc + .with_ymd_and_hms(2026, 2, 25, 0, 0, 10) + .single() + .unwrap() + .timestamp() as u64; + + assert!(should_emit("response_end", "cross-day", first, 86_400 * 2)); + assert!(!should_emit( + "response_end", + "cross-day", + second, + 86_400 * 2 + )); + }); + } + + #[test] + #[serial] + fn test_should_emit_creates_hash_fanout_path() { + with_temp_dedupe_dir(|base| { + let now = 1_700_000_000; + assert!(should_emit("session_start", "my-session", now, 60)); + + let hash = key_hash("my-session"); + let day = utc_day_from_ts(now).format("%Y-%m-%d").to_string(); + let path = marker_path(base, "session_start", &day, &hash); + assert!(path.exists()); + assert_eq!( + path.parent() + .and_then(|p| p.file_name()) + .and_then(|v| v.to_str()), + Some(&hash[..2]) + ); + }); + } + + #[test] + #[serial] + fn test_should_emit_overwrites_existing_marker_file() { + with_temp_dedupe_dir(|base| { + let now = 1_700_000_000; + assert!(should_emit("response_end", "overwrite", now, 0)); + assert!(should_emit("response_end", "overwrite", now + 1, 0)); + + let hash = key_hash("overwrite"); + let day = utc_day_from_ts(now + 1).format("%Y-%m-%d").to_string(); + let path = marker_path(base, "response_end", &day, &hash); + let ts = std::fs::read_to_string(path).unwrap(); + assert_eq!(ts.trim(), (now + 1).to_string()); + }); + } + + #[test] + #[serial] + fn test_malformed_timestamp_fails_open_and_self_heals() { + with_temp_dedupe_dir(|base| { + let now = 1_700_000_000; + let hash = key_hash("broken"); + let day = utc_day_from_ts(now).format("%Y-%m-%d").to_string(); + let path = marker_path(base, "response_start", &day, &hash); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "not-a-timestamp").unwrap(); + + assert!(should_emit("response_start", "broken", now, 10_000)); + let healed = std::fs::read_to_string(path).unwrap(); + assert_eq!(healed.trim(), now.to_string()); + }); + } + + #[test] + #[serial] + fn test_cleanup_old_days_removes_old_day_directories() { + with_temp_dedupe_dir(|base| { + let now = Utc + .with_ymd_and_hms(2026, 2, 24, 12, 0, 0) + .single() + .unwrap() + .timestamp() as u64; + + let old_day = (utc_day_from_ts(now) - Duration::days(8)) + .format("%Y-%m-%d") + .to_string(); + let keep_day = (utc_day_from_ts(now) - Duration::days(2)) + .format("%Y-%m-%d") + .to_string(); + + std::fs::create_dir_all(base.join("agent_usage").join(&old_day).join("ab")).unwrap(); + std::fs::write( + base.join("agent_usage") + .join(&old_day) + .join("ab") + .join("old.ts"), + "1", + ) + .unwrap(); + + std::fs::create_dir_all(base.join("agent_usage").join(&keep_day).join("cd")).unwrap(); + std::fs::write( + base.join("agent_usage") + .join(&keep_day) + .join("cd") + .join("keep.ts"), + "1", + ) + .unwrap(); + + cleanup_old_days(now).unwrap(); + + assert!(!base.join("agent_usage").join(old_day).exists()); + assert!(base.join("agent_usage").join(keep_day).exists()); + }); + } + + #[test] + #[serial] + fn test_large_volume_cleanup_is_day_based() { + with_temp_dedupe_dir(|base| { + let now = Utc + .with_ymd_and_hms(2026, 2, 24, 12, 0, 0) + .single() + .unwrap() + .timestamp() as u64; + let old_ts = now - 86_400 * 10; + + for i in 0..3_000 { + let key = format!("key-{i}"); + assert!(should_emit("response_end", &key, old_ts, 60)); + } + + cleanup_old_days(now).unwrap(); + + let old_day = utc_day_from_ts(old_ts).format("%Y-%m-%d").to_string(); + assert!(!base.join("response_end").join(old_day).exists()); + }); + } + + #[test] + #[serial] + fn test_concurrent_should_emit_calls_do_not_panic() { + with_temp_dedupe_dir(|_| { + let thread_count = 32; + let barrier = Arc::new(Barrier::new(thread_count)); + let mut handles = Vec::new(); + + for idx in 0..thread_count { + let barrier = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + barrier.wait(); + for i in 0..200 { + let ts = 1_700_000_000 + i as u64; + let key = format!("shared-key-{}", i % 10); + let _ = should_emit("response_start", &key, ts + idx as u64, 30); + } + })); + } + + for handle in handles { + handle.join().expect("thread should complete without panic"); + } + }); + } +} diff --git a/src/metrics/events.rs b/src/metrics/events.rs index 7df118f23..a31d5b98c 100644 --- a/src/metrics/events.rs +++ b/src/metrics/events.rs @@ -710,6 +710,7 @@ impl AgentSessionValues { self } + #[allow(dead_code)] pub fn duration_ms(mut self, value: u64) -> Self { self.duration_ms = Some(Some(value)); self diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 6129fb33f..d8e5fdfce 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -7,6 +7,7 @@ pub mod attrs; pub mod db; +pub mod dedupe_fs; pub mod events; pub mod pos_encoded; pub mod types; @@ -21,6 +22,73 @@ pub use events::{ pub use pos_encoded::PosEncoded; pub use types::{EventValues, METRICS_API_VERSION, MetricEvent, MetricsBatch}; +#[cfg(any(test, feature = "test-support"))] +mod test_capture { + use super::MetricEvent; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Mutex, OnceLock}; + use std::thread::{self, ThreadId}; + + static CAPTURE_ENABLED: AtomicBool = AtomicBool::new(false); + static CAPTURED_EVENTS: OnceLock>> = OnceLock::new(); + static CAPTURE_OWNER: OnceLock>> = OnceLock::new(); + + fn storage() -> &'static Mutex> { + CAPTURED_EVENTS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn owner() -> &'static Mutex> { + CAPTURE_OWNER.get_or_init(|| Mutex::new(None)) + } + + pub(super) fn start() { + if let Ok(mut current_owner) = owner().lock() { + *current_owner = Some(thread::current().id()); + } + CAPTURE_ENABLED.store(true, Ordering::Relaxed); + if let Ok(mut events) = storage().lock() { + events.clear(); + } + } + + pub(super) fn take() -> Vec { + CAPTURE_ENABLED.store(false, Ordering::Relaxed); + if let Ok(mut current_owner) = owner().lock() { + *current_owner = None; + } + if let Ok(mut events) = storage().lock() { + return std::mem::take(&mut *events); + } + Vec::new() + } + + pub(super) fn push(event: MetricEvent) { + if !CAPTURE_ENABLED.load(Ordering::Relaxed) { + return; + } + if let Ok(current_owner) = owner().lock() + && current_owner.as_ref() != Some(&thread::current().id()) + { + return; + } + if let Ok(mut events) = storage().lock() { + events.push(event); + } + } +} + +#[cfg(any(test, feature = "test-support"))] +#[allow(dead_code)] +pub(crate) fn test_start_metric_capture() { + test_capture::start(); +} + +#[cfg(any(test, feature = "test-support"))] +#[allow(dead_code)] +pub(crate) fn test_take_captured_metrics() -> Vec { + test_capture::take() +} + /// Record an event with values and attributes. /// /// Events are written immediately to the observability log file. @@ -49,6 +117,10 @@ pub use types::{EventValues, METRICS_API_VERSION, MetricEvent, MetricsBatch}; /// ``` pub fn record(values: V, attrs: EventAttributes) { let event = MetricEvent::new(&values, attrs.to_sparse()); + + #[cfg(any(test, feature = "test-support"))] + test_capture::push(event.clone()); + // Write directly to observability log crate::observability::log_metrics(vec![event]); } diff --git a/src/observability/mod.rs b/src/observability/mod.rs index 7d44e4843..fb4d33378 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use crate::metrics::{METRICS_API_VERSION, MetricEvent}; +use crate::metrics::MetricEvent; pub mod flush; pub mod wrapper_performance_targets; @@ -63,6 +63,7 @@ enum LogEnvelope { Performance(PerformanceEnvelope), #[allow(dead_code)] Message(MessageEnvelope), + #[allow(dead_code)] Metrics(MetricsEnvelope), } @@ -248,7 +249,7 @@ pub fn log_metrics( let envelope = MetricsEnvelope { event_type: "metrics".to_string(), timestamp: chrono::Utc::now().to_rfc3339(), - version: METRICS_API_VERSION, + version: crate::metrics::METRICS_API_VERSION, events: chunk.to_vec(), }; diff --git a/tests/cursor.rs b/tests/cursor.rs index f0873f091..7ac1ac9d0 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -361,6 +361,78 @@ fn test_cursor_preset_session_start_telemetry_only() { ); } +#[test] +fn test_cursor_preset_precompact_telemetry_only() { + use git_ai::authorship::working_log::CheckpointKind; + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + }; + + let hook_input = r##"{ + "conversation_id": "test-conversation-id", + "workspace_roots": ["/Users/test/workspace"], + "hook_event_name": "preCompact", + "trigger": "auto", + "model": "gpt-5" + }"##; + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = CursorPreset; + let result = preset + .run(flags) + .expect("Should parse preCompact hook payload"); + + assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); + assert_eq!(result.hook_event_name.as_deref(), Some("preCompact")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + +#[test] +fn test_cursor_preset_before_read_file_telemetry_only() { + use git_ai::authorship::working_log::CheckpointKind; + use git_ai::commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + }; + + let hook_input = r##"{ + "conversation_id": "test-conversation-id", + "workspace_roots": ["/Users/test/workspace"], + "hook_event_name": "beforeReadFile", + "file_path": "/Users/test/workspace/src/main.rs", + "model": "gpt-5" + }"##; + + let flags = AgentCheckpointFlags { + hook_input: Some(hook_input.to_string()), + }; + + let preset = CursorPreset; + let result = preset + .run(flags) + .expect("Should parse beforeReadFile hook payload"); + + assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); + assert_eq!(result.hook_event_name.as_deref(), Some("beforeReadFile")); + assert_eq!( + result + .telemetry_payload + .as_ref() + .and_then(|m| m.get("telemetry_only")) + .map(String::as_str), + Some("1") + ); +} + #[test] fn test_cursor_e2e_with_attribution() { use std::fs; From 4778f9d76e092c0b390155a395d0921b30be6856 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Thu, 26 Feb 2026 11:11:58 -0500 Subject: [PATCH 06/11] Update agent-support/opencode/git-ai.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- agent-support/opencode/git-ai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-support/opencode/git-ai.ts b/agent-support/opencode/git-ai.ts index e5b8e5820..a9aefc02d 100644 --- a/agent-support/opencode/git-ai.ts +++ b/agent-support/opencode/git-ai.ts @@ -67,7 +67,7 @@ export const GitAiPlugin: Plugin = async (ctx) => { } const getSessionId = (event: any): string | null => { - return event?.sessionID ?? event?.sessionId ?? event?.session?.id ?? event?.id ?? null + return event?.sessionID ?? event?.sessionId ?? event?.session?.id ?? null } const getCwd = (event: any): string => { From ce6833ac5f6a6960e78e30bcd5bff580da60d8f3 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Thu, 26 Feb 2026 11:41:04 -0500 Subject: [PATCH 07/11] Align session dedupe TTL with dedupe marker retention --- src/commands/checkpoint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index 5f0adde0d..39fd86ac6 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -45,7 +45,7 @@ use crate::authorship::working_log::AgentId; /// This is half of the server-side bucketing window. const AGENT_USAGE_MIN_INTERVAL_SECS: u64 = 150; const RESPONSE_DEDUPE_TTL_SECS: u64 = 60 * 60 * 24; -const SESSION_DEDUPE_TTL_SECS: u64 = 60 * 60 * 24 * 7; +const SESSION_DEDUPE_TTL_SECS: u64 = 60 * 60 * 24 * 5; /// Build EventAttributes with repo metadata. /// Reused for both AgentUsage and Checkpoint events. From 0fa92ca89bf28b2443cf2f32f9ed5f145285a513 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 28 Feb 2026 23:52:49 -0500 Subject: [PATCH 08/11] Refactor checkpoint telemetry to typed events and shared payload parsing --- src/commands/checkpoint.rs | 879 +++++-------- .../checkpoint_agent/agent_presets.rs | 1120 ++++++++++++++--- .../checkpoint_agent/agent_v1_preset.rs | 14 +- src/commands/checkpoint_agent/mod.rs | 2 + .../checkpoint_agent/opencode_preset.rs | 195 ++- .../checkpoint_agent/telemetry_events.rs | 129 ++ .../checkpoint_agent/telemetry_payload.rs | 109 ++ src/commands/git_ai_handlers.rs | 17 +- src/git/test_utils/mod.rs | 9 +- src/metrics/attrs.rs | 2 + tests/claude_code.rs | 31 +- tests/codex.rs | 54 +- tests/cursor.rs | 62 +- tests/github_copilot.rs | 143 ++- tests/opencode.rs | 31 +- 15 files changed, 1881 insertions(+), 916 deletions(-) create mode 100644 src/commands/checkpoint_agent/telemetry_events.rs create mode 100644 src/commands/checkpoint_agent/telemetry_payload.rs diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index bc90a2eb5..6d49417fe 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -10,7 +10,13 @@ use crate::authorship::imara_diff_utils::{LineChangeTag, compute_line_changes}; use crate::authorship::working_log::CheckpointKind; use crate::authorship::working_log::{Checkpoint, WorkingLogEntry}; use crate::commands::blame::{GitAiBlameOptions, OLDEST_AI_BLAME_DATE}; -use crate::commands::checkpoint_agent::agent_presets::AgentRunResult; +use crate::commands::checkpoint_agent::agent_presets::{ + AgentRunResult, CheckpointExecution, NoOpReason, +}; +use crate::commands::checkpoint_agent::telemetry_events::{ + AgentTelemetryEvent, McpCallPhase, ResponsePhase, SessionPhase, SkillDetectionMethod, + SubagentPhase, TelemetrySignal, ToolCallPhase, +}; use crate::config::Config; use crate::error::GitAiError; use crate::git::repo_storage::PersistedWorkingLog; @@ -111,408 +117,235 @@ fn should_emit_telemetry_once( crate::metrics::dedupe_fs::should_emit(namespace, event_key, now_ts, ttl_secs) } -fn payload_str<'a>(result: &'a AgentRunResult, key: &str) -> Option<&'a str> { - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get(key)) - .map(|s| s.as_str()) -} - -fn payload_u32(result: &AgentRunResult, key: &str) -> Option { - payload_str(result, key).and_then(|s| s.parse::().ok()) -} - -fn payload_u64(result: &AgentRunResult, key: &str) -> Option { - payload_str(result, key).and_then(|s| s.parse::().ok()) +fn signal_is_inferred(signal: &TelemetrySignal) -> bool { + matches!(signal, TelemetrySignal::Inferred) } -fn payload_is_true(result: &AgentRunResult, key: &str) -> bool { - matches!(payload_str(result, key), Some("1" | "true" | "yes")) -} - -fn is_human_like_role(role: &str) -> bool { - matches!(role.to_ascii_lowercase().as_str(), "human" | "user") -} - -fn should_emit_human_message(result: &AgentRunResult, hook: &str) -> bool { - if matches!(hook, "UserPromptSubmit" | "beforeSubmitPrompt") { - return true; +fn tool_call_phase_str(phase: &ToolCallPhase) -> &'static str { + match phase { + ToolCallPhase::Started => "started", + ToolCallPhase::Ended => "ended", + ToolCallPhase::Failed => "failed", + ToolCallPhase::PermissionRequested => "permission_requested", } - - if payload_u32(result, "prompt_char_count").is_none() { - return false; - } - - match payload_str(result, "role") { - Some(role) => is_human_like_role(role), - None => true, - } -} - -fn is_transcript_inferred_source(result: &AgentRunResult) -> bool { - matches!(result.hook_source.as_deref(), Some("codex_notify")) } -fn tool_phase_from_hook(hook: &str) -> Option<&'static str> { - match hook { - "PreToolUse" | "preToolUse" | "tool.execute.before" | "before_edit" => Some("started"), - "PostToolUse" | "postToolUse" | "tool.execute.after" | "after_edit" => Some("ended"), - "PostToolUseFailure" | "postToolUseFailure" => Some("failed"), - "PermissionRequest" => Some("permission_requested"), - _ => None, +fn mcp_call_phase_str(phase: &McpCallPhase) -> &'static str { + match phase { + McpCallPhase::Started => "started", + McpCallPhase::Ended => "ended", + McpCallPhase::Failed => "failed", + McpCallPhase::PermissionRequested => "permission_requested", } } -fn has_mcp_context(result: &AgentRunResult, tool_name: Option<&str>) -> bool { - payload_str(result, "mcp_tool_name").is_some() - || payload_str(result, "mcp_server").is_some() - || payload_str(result, "mcp_transport").is_some() - || tool_name.is_some_and(|name| name.starts_with("mcp__")) -} - -fn mcp_phase_from_hook( - hook: &str, - result: &AgentRunResult, - tool_name: Option<&str>, -) -> Option<&'static str> { - if matches!(hook, "beforeMCPExecution") { - return Some("started"); - } - if matches!(hook, "afterMCPExecution") { - return Some("ended"); - } - - if !has_mcp_context(result, tool_name) { - return None; - } - - match hook { - "PreToolUse" | "preToolUse" | "tool.execute.before" => Some("started"), - "PostToolUse" | "postToolUse" | "tool.execute.after" => Some("ended"), - "PostToolUseFailure" | "postToolUseFailure" => Some("failed"), - "PermissionRequest" => Some("permission_requested"), - _ => None, +fn response_phase_str(phase: &ResponsePhase) -> &'static str { + match phase { + ResponsePhase::Started => "started", + ResponsePhase::Ended => "ended", } } -type ResponsePhase = (&'static str, u32); -type ResponsePhases = (Option, Option); - -fn response_phases_from_hook(hook: &str, result: &AgentRunResult) -> ResponsePhases { - if hook == "message.part.updated" { - let is_human_role = payload_str(result, "role").is_some_and(is_human_like_role); - if is_human_role || payload_u32(result, "response_char_count").is_none() { - return (None, None); - } - return (Some(("started", 0)), None); - } - if hook == "message.updated" { - let is_human_role = payload_str(result, "role").is_some_and(is_human_like_role); - if is_human_role || payload_u32(result, "response_char_count").is_none() { - return (None, None); - } - return (None, Some(("ended", 0))); - } - if matches!( - hook, - "afterAgentResponse" | "session.idle" | "Stop" | "stop" - ) { - return (None, Some(("ended", 0))); - } - if matches!(hook, "afterAgentThought") { - return (Some(("started", 1)), None); - } - if matches!( - hook, - "PreToolUse" - | "preToolUse" - | "SubagentStart" - | "subagentStart" - | "tool.execute.before" - | "before_edit" - ) { - return (Some(("started", 1)), None); - } - if matches!( - hook, - "PostToolUse" - | "postToolUse" - | "PostToolUseFailure" - | "postToolUseFailure" - | "tool.execute.after" - | "after_edit" - ) { - return (None, Some(("ended", 1))); - } - - if result.agent_id.tool == "codex" { - return (Some(("started", 1)), Some(("ended", 0))); +fn subagent_phase_str(phase: &SubagentPhase) -> &'static str { + match phase { + SubagentPhase::Started => "started", + SubagentPhase::Ended => "ended", } - - (None, None) } -fn response_dedupe_generation(result: &AgentRunResult, hook: &str, now_ts: u64) -> String { - if let Some(generation_id) = payload_str(result, "generation_id") - && !generation_id.trim().is_empty() - { - return generation_id.to_string(); +fn session_phase_str(phase: &SessionPhase) -> &'static str { + match phase { + SessionPhase::Started => "started", + SessionPhase::Ended => "ended", } - - for key in ["tool_use_id", "subagent_id", "message_id"] { - if let Some(value) = payload_str(result, key) - && !value.trim().is_empty() - { - return value.to_string(); - } - } - - let mut fallback_parts = Vec::new(); - for key in [ - "prompt_char_count", - "response_char_count", - "input_message_count", - "tool_name", - "status", - "reason", - ] { - if let Some(value) = payload_str(result, key) - && !value.trim().is_empty() - { - fallback_parts.push(format!("{key}={value}")); - } - } - - if !fallback_parts.is_empty() { - return generate_short_hash(&fallback_parts.join("|"), hook); - } - - // Last resort: per-second key avoids collapsing an entire session into one bucket. - format!("ts-{now_ts}") } -fn normalized_hook_attrs( - mut attrs: crate::metrics::EventAttributes, - result: &AgentRunResult, -) -> crate::metrics::EventAttributes { - if let Some(override_tool) = payload_str(result, "agent_tool") { - attrs = attrs.tool(override_tool); - } - - let prompt_tool = payload_str(result, "agent_tool").unwrap_or(&result.agent_id.tool); - if let Some(session_id) = payload_str(result, "session_id") - && !session_id.trim().is_empty() +fn response_dedupe_key( + response: &crate::commands::checkpoint_agent::telemetry_events::AgentResponseTelemetry, + now_ts: u64, +) -> String { + if let Some(key) = &response.dedupe_key + && !key.trim().is_empty() { - let prompt_id = generate_short_hash(session_id, prompt_tool); - attrs = attrs.prompt_id(prompt_id).external_prompt_id(session_id); + return key.clone(); } - - if let Some(hook_event_name) = &result.hook_event_name { - attrs = attrs.hook_event_name(hook_event_name); - } - if let Some(hook_source) = &result.hook_source { - attrs = attrs.hook_source(hook_source); - } - attrs + format!("ts-{now_ts}") } -pub(crate) fn emit_agent_hook_telemetry( +pub(crate) fn emit_agent_telemetry_events( agent_run_result: Option<&AgentRunResult>, attrs: crate::metrics::EventAttributes, ) { let Some(result) = agent_run_result else { return; }; - let Some(hook_event_name) = result.hook_event_name.as_deref() else { + if result.telemetry_events.is_empty() { return; - }; + } - let attrs = normalized_hook_attrs(attrs, result); - let hook = hook_event_name; + let attrs = attrs; let now_ts = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let dedupe_generation = response_dedupe_generation(result, hook, now_ts); - - if matches!(hook, "SessionStart" | "sessionStart" | "session.created") { - let values = crate::metrics::AgentSessionValues::new() - .phase("started") - .reason(payload_str(result, "reason").unwrap_or("started")) - .source( - payload_str(result, "source") - .or(result.hook_source.as_deref()) - .unwrap_or("unknown"), - ) - .mode(payload_str(result, "mode").unwrap_or("unknown")) - .inferred(0); - crate::metrics::record(values, attrs.clone()); - } else if result.agent_id.tool == "codex" && is_transcript_inferred_source(result) { - let dedupe_key = format!( - "session-start:{}:{}", - result.agent_id.tool, result.agent_id.id - ); - if should_emit_telemetry_once( - "session_start", - &dedupe_key, - now_ts, - SESSION_DEDUPE_TTL_SECS, - ) { - let values = crate::metrics::AgentSessionValues::new() - .phase("started") - .source("inferred") - .mode("agent") - .inferred(1); - crate::metrics::record(values, attrs.clone()); - } - } - if should_emit_human_message(result, hook) { - let mut values = crate::metrics::AgentMessageValues::new().role("human"); - if let Some(char_count) = payload_u32(result, "prompt_char_count") { - values = values.prompt_char_count(char_count); - } - if let Some(attachment_count) = payload_u32(result, "attachment_count") { - values = values.attachment_count(attachment_count); - } - crate::metrics::record(values, attrs.clone()); - } - - let tool_name = payload_str(result, "tool_name"); - let phase = tool_phase_from_hook(hook); - if let Some(phase) = phase { - let mut values = crate::metrics::AgentToolCallValues::new().phase(phase); - if let Some(name) = tool_name { - values = values.tool_name(name.to_string()); - } - if let Some(tool_use_id) = payload_str(result, "tool_use_id") { - values = values.tool_use_id(tool_use_id.to_string()); - } - if let Some(duration_ms) = payload_u64(result, "duration_ms") { - values = values.duration_ms(duration_ms); - } - if let Some(failure_type) = payload_str(result, "failure_type") { - values = values.failure_type(failure_type.to_string()); - } - if payload_is_true(result, "inferred_tool") { - values = values.inferred(1); - } - crate::metrics::record(values, attrs.clone()); - } - - let mcp_phase = mcp_phase_from_hook(hook, result, tool_name); - if let Some(phase) = mcp_phase { - let mut values = crate::metrics::AgentMcpCallValues::new().phase(phase); - if let Some(server) = payload_str(result, "mcp_server") { - values = values.mcp_server(server.to_string()); - } - if let Some(name) = payload_str(result, "mcp_tool_name").or(tool_name) { - values = values.tool_name(name.to_string()); - } - if let Some(transport) = payload_str(result, "mcp_transport") { - values = values.transport(transport.to_string()); - } - if let Some(duration_ms) = payload_u64(result, "duration_ms") { - values = values.duration_ms(duration_ms); - } - if let Some(failure_type) = payload_str(result, "failure_type") { - values = values.failure_type(failure_type.to_string()); - } - if !matches!(hook, "beforeMCPExecution" | "afterMCPExecution") { - values = values.inferred(1); - } - crate::metrics::record(values, attrs.clone()); - } - - if let Some(skill_name) = payload_str(result, "skill_name") { - let mut values = crate::metrics::AgentSkillUsageValues::new().skill_name(skill_name); - if let Some(method) = payload_str(result, "skill_detection_method") { - values = values.detection_method(method); - } else { - values = values.detection_method("inferred_prompt"); - } - if !matches!( - payload_str(result, "skill_detection_method"), - Some("explicit") - ) { - values = values.inferred(1); - } - crate::metrics::record(values, attrs.clone()); - } - - let subagent_phase = match hook { - "SubagentStart" | "subagentStart" => Some("started"), - "SubagentStop" | "subagentStop" => Some("ended"), - _ => None, - }; - if let Some(phase) = subagent_phase { - let mut values = crate::metrics::AgentSubagentValues::new().phase(phase); - if let Some(subagent_id) = payload_str(result, "subagent_id") { - values = values.subagent_id(subagent_id); - } - if let Some(subagent_type) = payload_str(result, "subagent_type") { - values = values.subagent_type(subagent_type); - } - if let Some(status) = payload_str(result, "status") { - values = values.status(status); - } - if let Some(duration_ms) = payload_u64(result, "duration_ms") { - values = values.duration_ms(duration_ms); - } - if let Some(result_char_count) = payload_u32(result, "result_char_count") { - values = values.result_char_count(result_char_count); - } - crate::metrics::record(values, attrs.clone()); - } - - let (response_start_phase, response_end_phase) = response_phases_from_hook(hook, result); + for event in &result.telemetry_events { + match event { + AgentTelemetryEvent::Session(session) => { + if signal_is_inferred(&session.signal) { + let dedupe_key = format!( + "session-{}:{}:{}", + session_phase_str(&session.phase), + result.agent_id.tool, + result.agent_id.id + ); + if !should_emit_telemetry_once( + "session_event", + &dedupe_key, + now_ts, + SESSION_DEDUPE_TTL_SECS, + ) { + continue; + } + } - if let Some((phase, inferred)) = response_start_phase { - let dedupe_key = format!( - "response-start:{}:{}:{}", - result.agent_id.tool, result.agent_id.id, &dedupe_generation - ); - if should_emit_telemetry_once( - "response_start", - &dedupe_key, - now_ts, - RESPONSE_DEDUPE_TTL_SECS, - ) { - let mut values = crate::metrics::AgentResponseValues::new() - .phase(phase) - .inferred(inferred); - if let Some(reason) = payload_str(result, "reason") { - values = values.reason(reason); + let mut values = crate::metrics::AgentSessionValues::new() + .phase(session_phase_str(&session.phase)) + .inferred(u32::from(signal_is_inferred(&session.signal))); + if let Some(reason) = &session.reason { + values = values.reason(reason); + } + if let Some(source) = &session.source { + values = values.source(source); + } + if let Some(mode) = &session.mode { + values = values.mode(mode); + } + if let Some(duration_ms) = session.duration_ms { + values = values.duration_ms(duration_ms); + } + crate::metrics::record(values, attrs.clone()); } - crate::metrics::record(values, attrs.clone()); - } - } - - if let Some((phase, inferred)) = response_end_phase { - let dedupe_key = format!( - "response-end:{}:{}:{}", - result.agent_id.tool, result.agent_id.id, &dedupe_generation - ); - if should_emit_telemetry_once( - "response_end", - &dedupe_key, - now_ts, - RESPONSE_DEDUPE_TTL_SECS, - ) { - let mut values = crate::metrics::AgentResponseValues::new() - .phase(phase) - .inferred(inferred); - if let Some(status) = payload_str(result, "status") { - values = values.status(status); + AgentTelemetryEvent::Message(message) => { + let mut values = + crate::metrics::AgentMessageValues::new().role(match message.role { + crate::commands::checkpoint_agent::telemetry_events::MessageRole::Human => { + "human" + } + }); + if let Some(count) = message.prompt_char_count { + values = values.prompt_char_count(count); + } + if let Some(count) = message.attachment_count { + values = values.attachment_count(count); + } + crate::metrics::record(values, attrs.clone()); } - if let Some(reason) = payload_str(result, "reason") { - values = values.reason(reason); + AgentTelemetryEvent::ToolCall(tool) => { + let mut values = crate::metrics::AgentToolCallValues::new() + .phase(tool_call_phase_str(&tool.phase)) + .inferred(u32::from(signal_is_inferred(&tool.signal))); + if let Some(name) = &tool.tool_name { + values = values.tool_name(name.clone()); + } + if let Some(tool_use_id) = &tool.tool_use_id { + values = values.tool_use_id(tool_use_id.clone()); + } + if let Some(duration_ms) = tool.duration_ms { + values = values.duration_ms(duration_ms); + } + if let Some(failure_type) = &tool.failure_type { + values = values.failure_type(failure_type.clone()); + } + crate::metrics::record(values, attrs.clone()); } - if let Some(char_count) = payload_u32(result, "response_char_count") { - values = values.response_char_count(char_count); + AgentTelemetryEvent::McpCall(mcp) => { + let mut values = crate::metrics::AgentMcpCallValues::new() + .phase(mcp_call_phase_str(&mcp.phase)) + .inferred(u32::from(signal_is_inferred(&mcp.signal))); + if let Some(server) = &mcp.mcp_server { + values = values.mcp_server(server.clone()); + } + if let Some(name) = &mcp.tool_name { + values = values.tool_name(name.clone()); + } + if let Some(transport) = &mcp.transport { + values = values.transport(transport.clone()); + } + if let Some(duration_ms) = mcp.duration_ms { + values = values.duration_ms(duration_ms); + } + if let Some(failure_type) = &mcp.failure_type { + values = values.failure_type(failure_type.clone()); + } + crate::metrics::record(values, attrs.clone()); + } + AgentTelemetryEvent::SkillUsage(skill) => { + let mut values = crate::metrics::AgentSkillUsageValues::new() + .skill_name(&skill.skill_name) + .inferred(u32::from(signal_is_inferred(&skill.signal))); + values = values.detection_method(match skill.detection_method { + SkillDetectionMethod::Explicit => "explicit", + SkillDetectionMethod::InferredPrompt => "inferred_prompt", + SkillDetectionMethod::InferredTool => "inferred_tool", + }); + crate::metrics::record(values, attrs.clone()); + } + AgentTelemetryEvent::Subagent(subagent) => { + let mut values = crate::metrics::AgentSubagentValues::new() + .phase(subagent_phase_str(&subagent.phase)) + .inferred(u32::from(signal_is_inferred(&subagent.signal))); + if let Some(subagent_id) = &subagent.subagent_id { + values = values.subagent_id(subagent_id); + } + if let Some(subagent_type) = &subagent.subagent_type { + values = values.subagent_type(subagent_type); + } + if let Some(status) = &subagent.status { + values = values.status(status); + } + if let Some(duration_ms) = subagent.duration_ms { + values = values.duration_ms(duration_ms); + } + if let Some(result_char_count) = subagent.result_char_count { + values = values.result_char_count(result_char_count); + } + crate::metrics::record(values, attrs.clone()); + } + AgentTelemetryEvent::Response(response) => { + if signal_is_inferred(&response.signal) { + let dedupe_key = response_dedupe_key(response, now_ts); + let dedupe_key = format!( + "response-{}:{}:{}:{}", + response_phase_str(&response.phase), + result.agent_id.tool, + result.agent_id.id, + dedupe_key + ); + if !should_emit_telemetry_once( + "response_event", + &dedupe_key, + now_ts, + RESPONSE_DEDUPE_TTL_SECS, + ) { + continue; + } + } + + let mut values = crate::metrics::AgentResponseValues::new() + .phase(response_phase_str(&response.phase)) + .inferred(u32::from(signal_is_inferred(&response.signal))); + if let Some(status) = &response.status { + values = values.status(status); + } + if let Some(reason) = &response.reason { + values = values.reason(reason); + } + if let Some(response_char_count) = response.response_char_count { + values = values.response_char_count(response_char_count); + } + crate::metrics::record(values, attrs.clone()); } - crate::metrics::record(values, attrs); } } } @@ -562,16 +395,20 @@ pub fn run( storage_start.elapsed() )); - if agent_run_result - .as_ref() - .is_some_and(|r| payload_is_true(r, "telemetry_only")) + let telemetry_attrs = build_checkpoint_attrs( + repo, + &base_commit, + agent_run_result.as_ref().map(|r| &r.agent_id), + ); + emit_agent_telemetry_events(agent_run_result.as_ref(), telemetry_attrs); + if let Some(agent_run_result) = agent_run_result.as_ref() + && matches!( + agent_run_result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly | NoOpReason::NoEditedFiles + } + ) { - let telemetry_attrs = build_checkpoint_attrs( - repo, - &base_commit, - agent_run_result.as_ref().map(|r| &r.agent_id), - ); - emit_agent_hook_telemetry(agent_run_result.as_ref(), telemetry_attrs); return Ok((0, 0, 0)); } @@ -895,13 +732,6 @@ pub fn run( } } - let telemetry_attrs = build_checkpoint_attrs( - repo, - &base_commit, - agent_run_result.as_ref().map(|r| &r.agent_id), - ); - emit_agent_hook_telemetry(agent_run_result.as_ref(), telemetry_attrs); - let agent_tool = if kind != CheckpointKind::Human && let Some(agent_run_result) = &agent_run_result { @@ -1899,6 +1729,10 @@ fn upsert_checkpoint_prompt_to_db( #[cfg(test)] mod tests { use super::*; + use crate::commands::checkpoint_agent::telemetry_events::{ + AgentMcpCallTelemetry, AgentResponseTelemetry, AgentSessionTelemetry, + AgentSkillUsageTelemetry, AgentSubagentTelemetry, AgentToolCallTelemetry, + }; use crate::git::test_utils::TmpRepo; use serial_test::serial; use std::ffi::OsString; @@ -2071,7 +1905,9 @@ mod tests { fn test_checkpoint_with_paths_outside_repo() { use crate::authorship::transcript::AiTranscript; use crate::authorship::working_log::AgentId; - use crate::commands::checkpoint_agent::agent_presets::AgentRunResult; + use crate::commands::checkpoint_agent::agent_presets::{ + AgentRunResult, CheckpointExecution, + }; // Create a repo with an initial commit let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); @@ -2097,9 +1933,8 @@ mod tests { ]), will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }; // Run checkpoint - should not crash even with paths outside repo @@ -2530,28 +2365,24 @@ mod tests { ); } - fn test_agent_run_result_for_telemetry(payload: &[(&str, &str)]) -> AgentRunResult { - let telemetry_payload = payload - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect::>(); - + fn test_agent_run_result_for_telemetry(events: Vec) -> AgentRunResult { AgentRunResult { agent_id: AgentId { - tool: "human".to_string(), - id: "human".to_string(), - model: "human".to_string(), + tool: "test-agent".to_string(), + id: "test-session".to_string(), + model: "test-model".to_string(), }, agent_metadata: None, - checkpoint_kind: CheckpointKind::Human, + checkpoint_kind: CheckpointKind::AiAgent, transcript: None, repo_working_dir: None, edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: Some("test".to_string()), - telemetry_payload: Some(telemetry_payload), + checkpoint_execution: CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly, + }, + telemetry_events: events, } } @@ -2591,132 +2422,27 @@ mod tests { fn capture_hook_metrics(result: &AgentRunResult) -> Vec { crate::metrics::test_start_metric_capture(); - emit_agent_hook_telemetry( + emit_agent_telemetry_events( Some(result), crate::metrics::EventAttributes::with_version("1.0.0"), ); crate::metrics::test_take_captured_metrics() } - #[test] - fn test_tool_phase_from_hook_supports_legacy_copilot_events() { - assert_eq!(tool_phase_from_hook("before_edit"), Some("started")); - assert_eq!(tool_phase_from_hook("after_edit"), Some("ended")); - } - - #[test] - fn test_mcp_phase_from_hook_supports_opencode_tool_execute_events() { - let result = test_agent_run_result_for_telemetry(&[ - ("tool_name", "mcp__list_files"), - ("mcp_tool_name", "mcp__list_files"), - ]); - - assert_eq!( - mcp_phase_from_hook("tool.execute.before", &result, Some("mcp__list_files")), - Some("started") - ); - assert_eq!( - mcp_phase_from_hook("tool.execute.after", &result, Some("mcp__list_files")), - Some("ended") - ); - } - - #[test] - fn test_response_phases_from_hook_supports_opencode_message_hooks() { - let assistant = test_agent_run_result_for_telemetry(&[ - ("role", "assistant"), - ("response_char_count", "12"), - ]); - - assert_eq!( - response_phases_from_hook("message.part.updated", &assistant), - (Some(("started", 0)), None) - ); - assert_eq!( - response_phases_from_hook("message.updated", &assistant), - (None, Some(("ended", 0))) - ); - - let human = test_agent_run_result_for_telemetry(&[ - ("role", "human"), - ("response_char_count", "12"), - ]); - assert_eq!( - response_phases_from_hook("message.updated", &human), - (None, None) - ); - } - - #[test] - fn test_response_dedupe_generation_avoids_constant_na_when_generation_missing() { - let with_tool_use_id = test_agent_run_result_for_telemetry(&[ - ("tool_use_id", "call-123"), - ("response_char_count", "8"), - ]); - assert_eq!( - response_dedupe_generation(&with_tool_use_id, "agent-turn-complete", 123), - "call-123" - ); - - let fallback = test_agent_run_result_for_telemetry(&[ - ("prompt_char_count", "10"), - ("response_char_count", "8"), - ]); - let key = response_dedupe_generation(&fallback, "agent-turn-complete", 123); - assert_ne!(key, "na"); - assert!(!key.is_empty()); - } - - #[test] - fn test_should_emit_human_message_ignores_assistant_roles() { - let assistant = test_agent_run_result_for_telemetry(&[ - ("role", "assistant"), - ("prompt_char_count", "42"), - ]); - assert!(!should_emit_human_message(&assistant, "message.updated")); - - let human = - test_agent_run_result_for_telemetry(&[("role", "user"), ("prompt_char_count", "42")]); - assert!(should_emit_human_message(&human, "message.updated")); - } - - #[test] - fn test_normalized_hook_attrs_uses_session_id_for_prompt_identity() { - let result = test_agent_run_result_for_telemetry(&[ - ("agent_tool", "github-copilot"), - ("session_id", "copilot-session-123"), - ]); - - let attrs = normalized_hook_attrs( - crate::metrics::EventAttributes::with_version("1.0.0") - .tool("human") - .prompt_id("old") - .external_prompt_id("old"), - &result, - ); - - let expected_prompt_id = generate_short_hash("copilot-session-123", "github-copilot"); - assert_eq!( - attrs.prompt_id, - Some(Some(expected_prompt_id)), - "prompt_id should be derived from session_id + agent_tool when available" - ); - assert_eq!( - attrs.external_prompt_id, - Some(Some("copilot-session-123".to_string())) - ); - assert_eq!(attrs.tool, Some(Some("github-copilot".to_string()))); - } - #[test] #[serial] - fn test_emit_agent_hook_telemetry_emits_session_start_only() { + fn test_emit_agent_telemetry_events_emits_session_phases() { with_temp_dedupe_dir(|| { - let mut start = - test_agent_run_result_for_telemetry(&[("source", "new"), ("mode", "agent")]); - start.agent_id.tool = "claude".to_string(); - start.hook_source = Some("claude_hook".to_string()); - start.hook_event_name = Some("SessionStart".to_string()); + let start = test_agent_run_result_for_telemetry(vec![AgentTelemetryEvent::Session( + AgentSessionTelemetry { + phase: SessionPhase::Started, + reason: None, + source: Some("claude_hook".to_string()), + mode: Some("agent".to_string()), + duration_ms: None, + signal: TelemetrySignal::Explicit, + }, + )]); let start_events = capture_hook_metrics(&start); assert!(start_events.iter().any(|event| event.event_id == 5)); @@ -2729,29 +2455,45 @@ mod tests { Some("started") ); - let mut end = start.clone(); - end.hook_event_name = Some("SessionEnd".to_string()); + let end = test_agent_run_result_for_telemetry(vec![AgentTelemetryEvent::Session( + AgentSessionTelemetry { + phase: SessionPhase::Ended, + reason: Some("completed".to_string()), + source: Some("claude_hook".to_string()), + mode: Some("agent".to_string()), + duration_ms: Some(50), + signal: TelemetrySignal::Explicit, + }, + )]); let end_events = capture_hook_metrics(&end); - assert!( - !end_events.iter().any(|event| event.event_id == 5), - "SessionEnd should not emit agent_session metrics" + let session_event = end_events + .iter() + .find(|event| event.event_id == 5) + .expect("expected an AgentSession metric for session end"); + assert_eq!( + session_event.values.get("0").and_then(|v| v.as_str()), + Some("ended") ); }); } #[test] #[serial] - fn test_emit_agent_hook_telemetry_dedupes_response_updates() { + fn test_emit_agent_telemetry_events_dedupes_inferred_response_updates() { with_temp_dedupe_dir(|| { - let mut result = test_agent_run_result_for_telemetry(&[ - ("role", "assistant"), - ("message_id", "msg-123"), - ("response_char_count", "12"), - ]); + let mut result = + test_agent_run_result_for_telemetry(vec![AgentTelemetryEvent::Response( + AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: None, + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: Some("msg-123".to_string()), + }, + )]); result.agent_id.tool = "opencode".to_string(); result.agent_id.id = "session-123".to_string(); - result.hook_source = Some("opencode_plugin".to_string()); - result.hook_event_name = Some("message.part.updated".to_string()); let first = capture_hook_metrics(&result); let first_count = first.iter().filter(|event| event.event_id == 7).count(); @@ -2766,29 +2508,68 @@ mod tests { second_count, 0, "second update with same generation key should be deduped" ); + + let explicit_result = + test_agent_run_result_for_telemetry(vec![AgentTelemetryEvent::Response( + AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: None, + status: Some("completed".to_string()), + response_char_count: Some(12), + signal: TelemetrySignal::Explicit, + dedupe_key: Some("msg-123".to_string()), + }, + )]); + let explicit_first = capture_hook_metrics(&explicit_result); + let explicit_second = capture_hook_metrics(&explicit_result); + assert_eq!( + explicit_first + .iter() + .filter(|event| event.event_id == 7) + .count(), + 1 + ); + assert_eq!( + explicit_second + .iter() + .filter(|event| event.event_id == 7) + .count(), + 1, + "explicit response events must not be deduped" + ); }); } #[test] #[serial] - fn test_emit_agent_hook_telemetry_maps_tool_mcp_subagent_and_skill() { + fn test_emit_agent_telemetry_events_maps_tool_mcp_subagent_and_skill() { with_temp_dedupe_dir(|| { - let mut result = test_agent_run_result_for_telemetry(&[ - ("tool_name", "mcp__fs__read"), - ("tool_use_id", "tool-123"), - ("mcp_server", "fs"), - ("mcp_transport", "stdio"), - ("subagent_id", "subagent-1"), - ("subagent_type", "Plan"), - ("status", "completed"), - ("skill_name", "checks"), - ("skill_detection_method", "inferred_prompt"), - ("duration_ms", "45"), + let mut result = test_agent_run_result_for_telemetry(vec![ + AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Started, + tool_name: Some("mcp__fs__read".to_string()), + tool_use_id: Some("tool-123".to_string()), + duration_ms: Some(45), + failure_type: None, + signal: TelemetrySignal::Explicit, + }), + AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Started, + mcp_server: Some("fs".to_string()), + tool_name: Some("mcp__fs__read".to_string()), + transport: Some("stdio".to_string()), + duration_ms: Some(45), + failure_type: None, + signal: TelemetrySignal::Inferred, + }), + AgentTelemetryEvent::SkillUsage(AgentSkillUsageTelemetry { + skill_name: "checks".to_string(), + detection_method: SkillDetectionMethod::InferredPrompt, + signal: TelemetrySignal::Inferred, + }), ]); result.agent_id.tool = "cursor".to_string(); result.agent_id.id = "cursor-session".to_string(); - result.hook_source = Some("cursor_hook".to_string()); - result.hook_event_name = Some("preToolUse".to_string()); let events = capture_hook_metrics(&result); @@ -2823,8 +2604,20 @@ mod tests { Some("checks") ); - let mut subagent_result = result.clone(); - subagent_result.hook_event_name = Some("subagentStart".to_string()); + let mut subagent_result = + test_agent_run_result_for_telemetry(vec![AgentTelemetryEvent::Subagent( + AgentSubagentTelemetry { + phase: SubagentPhase::Started, + subagent_id: Some("subagent-1".to_string()), + subagent_type: Some("Plan".to_string()), + status: Some("completed".to_string()), + duration_ms: Some(45), + result_char_count: Some(120), + signal: TelemetrySignal::Explicit, + }, + )]); + subagent_result.agent_id.tool = "cursor".to_string(); + subagent_result.agent_id.id = "cursor-session".to_string(); let subagent_events = capture_hook_metrics(&subagent_result); let subagent = subagent_events .iter() diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 147cedda9..22bb113d8 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -3,6 +3,13 @@ use crate::{ transcript::{AiTranscript, Message}, working_log::{AgentId, CheckpointKind}, }, + commands::checkpoint_agent::telemetry_events::{ + AgentMcpCallTelemetry, AgentMessageTelemetry, AgentResponseTelemetry, + AgentSessionTelemetry, AgentSkillUsageTelemetry, AgentSubagentTelemetry, + AgentTelemetryEvent, AgentToolCallTelemetry, McpCallPhase, MessageRole, ResponsePhase, + SessionPhase, SkillDetectionMethod, SubagentPhase, TelemetrySignal, ToolCallPhase, + }, + commands::checkpoint_agent::telemetry_payload::TelemetryPayloadView, error::GitAiError, observability::log_error, }; @@ -29,9 +36,21 @@ pub struct AgentRunResult { pub edited_filepaths: Option>, pub will_edit_filepaths: Option>, pub dirty_files: Option>, - pub hook_event_name: Option, - pub hook_source: Option, - pub telemetry_payload: Option>, + pub checkpoint_execution: CheckpointExecution, + pub telemetry_events: Vec, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NoOpReason { + TelemetryOnly, + NoEditedFiles, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CheckpointExecution { + Run, + NoOp { reason: NoOpReason }, } pub trait AgentCheckpointPreset { @@ -220,9 +239,77 @@ fn is_file_edit_tool_name(tool_name: &str) -> bool { matches!(tool_name, "Write" | "Edit" | "MultiEdit" | "NotebookEdit") } +fn payload_get<'a>(payload: &'a HashMap, key: &str) -> Option<&'a str> { + payload.get(key).map(String::as_str) +} + +fn payload_has_mcp_context(payload: &HashMap) -> bool { + payload_get(payload, "mcp_tool_name").is_some() + || payload_get(payload, "mcp_server").is_some() + || payload_get(payload, "mcp_transport").is_some() + || payload_get(payload, "tool_name").is_some_and(|name| name.starts_with("mcp__")) +} + +fn maybe_skill_event(payload: &HashMap) -> Option { + let skill_name = payload_get(payload, "skill_name")?; + let detection_method = match payload_get(payload, "skill_detection_method") { + Some("explicit") => SkillDetectionMethod::Explicit, + Some("inferred_tool") => SkillDetectionMethod::InferredTool, + _ => SkillDetectionMethod::InferredPrompt, + }; + let signal = if matches!(detection_method, SkillDetectionMethod::Explicit) { + TelemetrySignal::Explicit + } else { + TelemetrySignal::Inferred + }; + Some(AgentTelemetryEvent::SkillUsage(AgentSkillUsageTelemetry { + skill_name: skill_name.to_string(), + detection_method, + signal, + })) +} + // Claude Code to checkpoint preset pub struct ClaudePreset; +#[derive(Clone, Debug, PartialEq, Eq)] +enum ClaudeHookEvent { + SessionStart, + SessionEnd, + UserPromptSubmit, + PermissionRequest, + PreToolUse, + PostToolUse, + PostToolUseFailure, + SubagentStart, + SubagentStop, + Stop, + PreCompact, + Notification, + Unknown(String), +} + +impl ClaudeHookEvent { + fn parse(value: Option<&str>) -> Self { + match value { + Some("SessionStart") => Self::SessionStart, + Some("SessionEnd") => Self::SessionEnd, + Some("UserPromptSubmit") => Self::UserPromptSubmit, + Some("PermissionRequest") => Self::PermissionRequest, + Some("PreToolUse") => Self::PreToolUse, + Some("PostToolUse") => Self::PostToolUse, + Some("PostToolUseFailure") => Self::PostToolUseFailure, + Some("SubagentStart") => Self::SubagentStart, + Some("SubagentStop") => Self::SubagentStop, + Some("Stop") => Self::Stop, + Some("PreCompact") => Self::PreCompact, + Some("Notification") => Self::Notification, + Some(other) => Self::Unknown(other.to_string()), + None => Self::Unknown("missing".to_string()), + } + } +} + impl AgentCheckpointPreset for ClaudePreset { fn run(&self, flags: AgentCheckpointFlags) -> Result { // Parse claude_hook_stdin as JSON @@ -247,10 +334,8 @@ impl AgentCheckpointPreset for ClaudePreset { )); } - let hook_event_name = hook_data - .get("hook_event_name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let hook_event = ClaudeHookEvent::parse(hook_event_name); let tool_name = hook_data .get("tool_name") @@ -258,8 +343,10 @@ impl AgentCheckpointPreset for ClaudePreset { .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())); let is_tool_hook = matches!( - hook_event_name.as_deref(), - Some("PreToolUse" | "PostToolUse" | "PostToolUseFailure") + hook_event, + ClaudeHookEvent::PreToolUse + | ClaudeHookEvent::PostToolUse + | ClaudeHookEvent::PostToolUseFailure ); let has_file_path_in_tool_input = hook_data .get("tool_input") @@ -271,19 +358,18 @@ impl AgentCheckpointPreset for ClaudePreset { || has_file_path_in_tool_input); let telemetry_only_hook = matches!( - hook_event_name.as_deref(), - Some( - "SessionStart" - | "SessionEnd" - | "UserPromptSubmit" - | "PermissionRequest" - | "SubagentStart" - | "SubagentStop" - | "Stop" - | "PreCompact" - | "Notification" - | "PostToolUseFailure" - ) + hook_event, + ClaudeHookEvent::SessionStart + | ClaudeHookEvent::SessionEnd + | ClaudeHookEvent::UserPromptSubmit + | ClaudeHookEvent::PermissionRequest + | ClaudeHookEvent::SubagentStart + | ClaudeHookEvent::SubagentStop + | ClaudeHookEvent::Stop + | ClaudeHookEvent::PreCompact + | ClaudeHookEvent::Notification + | ClaudeHookEvent::PostToolUseFailure + | ClaudeHookEvent::Unknown(_) ) || (is_tool_hook && !is_edit_tool_hook); let transcript_path = hook_data @@ -380,8 +466,6 @@ impl AgentCheckpointPreset for ClaudePreset { .as_deref() .map(|path| HashMap::from([("transcript_path".to_string(), path.to_string())])); - let hook_source = Some("claude_hook".to_string()); - let mut telemetry_payload = collect_common_hook_telemetry_payload(&hook_data); if let Some(session_id) = hook_data.get("session_id").and_then(|v| v.as_str()) { telemetry_payload.insert("session_id".to_string(), session_id.to_string()); @@ -400,16 +484,196 @@ impl AgentCheckpointPreset for ClaudePreset { "inferred_prompt".to_string(), ); } - if telemetry_only_hook { - telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); + let telemetry = TelemetryPayloadView::from_payload(&telemetry_payload); + let mut telemetry_events = Vec::new(); + + match &hook_event { + ClaudeHookEvent::SessionStart => { + telemetry_events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Started, + reason: telemetry.reason.clone(), + source: Some("claude_hook".to_string()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })) + } + ClaudeHookEvent::SessionEnd => { + telemetry_events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Ended, + reason: telemetry.reason.clone(), + source: Some("claude_hook".to_string()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })) + } + ClaudeHookEvent::UserPromptSubmit => { + telemetry_events.push(AgentTelemetryEvent::Message(AgentMessageTelemetry { + role: MessageRole::Human, + prompt_char_count: telemetry.prompt_char_count, + attachment_count: telemetry.attachment_count, + signal: TelemetrySignal::Explicit, + })); + } + ClaudeHookEvent::PermissionRequest => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::PermissionRequested, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::PermissionRequested, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Inferred, + })); + } + } + ClaudeHookEvent::PreToolUse => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Started, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Started, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Inferred, + })); + } + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + ClaudeHookEvent::PostToolUse => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Ended, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Ended, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Inferred, + })); + } + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + ClaudeHookEvent::PostToolUseFailure => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Failed, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Failed, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Inferred, + })); + } + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + ClaudeHookEvent::SubagentStart => { + telemetry_events.push(AgentTelemetryEvent::Subagent(AgentSubagentTelemetry { + phase: SubagentPhase::Started, + subagent_id: telemetry.subagent_id.clone(), + subagent_type: telemetry.subagent_type.clone(), + status: telemetry.status.clone(), + duration_ms: telemetry.duration_ms, + result_char_count: telemetry.result_char_count, + signal: TelemetrySignal::Explicit, + })); + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + ClaudeHookEvent::SubagentStop => { + telemetry_events.push(AgentTelemetryEvent::Subagent(AgentSubagentTelemetry { + phase: SubagentPhase::Ended, + subagent_id: telemetry.subagent_id.clone(), + subagent_type: telemetry.subagent_type.clone(), + status: telemetry.status.clone(), + duration_ms: telemetry.duration_ms, + result_char_count: telemetry.result_char_count, + signal: TelemetrySignal::Explicit, + })) + } + ClaudeHookEvent::Stop => { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })) + } + ClaudeHookEvent::PreCompact + | ClaudeHookEvent::Notification + | ClaudeHookEvent::Unknown(_) => {} + } + + if let Some(skill_event) = maybe_skill_event(&telemetry_payload) { + telemetry_events.push(skill_event); } - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }; - if hook_event_name.as_deref() == Some("PreToolUse") { + if matches!(hook_event, ClaudeHookEvent::PreToolUse) { // Early return for human checkpoint return Ok(AgentRunResult { agent_id, @@ -420,9 +684,8 @@ impl AgentCheckpointPreset for ClaudePreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, - hook_event_name, - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }); } @@ -436,9 +699,14 @@ impl AgentCheckpointPreset for ClaudePreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, - hook_event_name, - hook_source, - telemetry_payload, + checkpoint_execution: if telemetry_only_hook { + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly, + } + } else { + CheckpointExecution::Run + }, + telemetry_events, }) } } @@ -760,9 +1028,8 @@ impl AgentCheckpointPreset for GeminiPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }); } @@ -776,9 +1043,8 @@ impl AgentCheckpointPreset for GeminiPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }) } } @@ -974,9 +1240,8 @@ impl AgentCheckpointPreset for ContinueCliPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }); } @@ -990,9 +1255,8 @@ impl AgentCheckpointPreset for ContinueCliPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }) } } @@ -1106,6 +1370,24 @@ impl ContinueCliPreset { pub struct CodexPreset; +#[derive(Clone, Debug, PartialEq, Eq)] +enum CodexNotifyEvent { + AgentTurnComplete, + AfterAgent, + Unknown(String), +} + +impl CodexNotifyEvent { + fn parse(value: Option<&str>) -> Self { + match value { + Some("agent-turn-complete") => Self::AgentTurnComplete, + Some("after_agent") => Self::AfterAgent, + Some(other) => Self::Unknown(other.to_string()), + None => Self::Unknown("missing".to_string()), + } + } +} + impl AgentCheckpointPreset for CodexPreset { fn run(&self, flags: AgentCheckpointFlags) -> Result { let stdin_json = flags.hook_input.ok_or_else(|| { @@ -1125,7 +1407,7 @@ impl AgentCheckpointPreset for CodexPreset { .and_then(|v| v.as_str()) }) .map(|s| s.to_string()); - let hook_source = Some("codex_notify".to_string()); + let hook_event = CodexNotifyEvent::parse(hook_event_name.as_deref()); let mut telemetry_payload = collect_common_hook_telemetry_payload(&hook_data); if let Some(input_messages) = hook_data @@ -1171,11 +1453,56 @@ impl AgentCheckpointPreset for CodexPreset { last_assistant.chars().count().to_string(), ); } - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) + let telemetry = TelemetryPayloadView::from_payload_with_dedupe_fallback( + &telemetry_payload, + hook_event_name.as_deref(), + ); + + let mut telemetry_events = vec![AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Started, + reason: None, + source: Some("inferred".to_string()), + mode: Some("agent".to_string()), + duration_ms: None, + signal: TelemetrySignal::Inferred, + })]; + + if telemetry.prompt_char_count.is_some() { + telemetry_events.push(AgentTelemetryEvent::Message(AgentMessageTelemetry { + role: MessageRole::Human, + prompt_char_count: telemetry.prompt_char_count, + attachment_count: None, + signal: TelemetrySignal::Inferred, + })); + } + + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: None, + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + + let end_signal = match hook_event { + CodexNotifyEvent::AfterAgent | CodexNotifyEvent::AgentTurnComplete => { + TelemetrySignal::Explicit + } + CodexNotifyEvent::Unknown(_) => TelemetrySignal::Inferred, }; + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: None, + status: None, + response_char_count: telemetry.response_char_count, + signal: end_signal, + dedupe_key: telemetry.dedupe_key.clone(), + })); + + if let Some(skill_event) = maybe_skill_event(&telemetry_payload) { + telemetry_events.push(skill_event); + } let session_id = CodexPreset::session_id_from_hook_data(&hook_data).ok_or_else(|| { GitAiError::PresetError("session_id/thread_id not found in hook_input".to_string()) @@ -1250,9 +1577,8 @@ impl AgentCheckpointPreset for CodexPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, - hook_event_name, - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }) } } @@ -1507,6 +1833,55 @@ impl CodexPreset { // Cursor to checkpoint preset pub struct CursorPreset; +#[derive(Clone, Debug, PartialEq, Eq)] +enum CursorHookEvent { + SessionStart, + SessionEnd, + BeforeSubmitPrompt, + PreToolUse, + PostToolUse, + PostToolUseFailure, + SubagentStart, + SubagentStop, + BeforeShellExecution, + AfterShellExecution, + BeforeMcpExecution, + AfterMcpExecution, + BeforeReadFile, + AfterFileEdit, + AfterAgentResponse, + AfterAgentThought, + PreCompact, + Stop, + Unknown(String), +} + +impl CursorHookEvent { + fn parse(value: &str) -> Self { + match value { + "sessionStart" => Self::SessionStart, + "sessionEnd" => Self::SessionEnd, + "beforeSubmitPrompt" => Self::BeforeSubmitPrompt, + "preToolUse" => Self::PreToolUse, + "postToolUse" => Self::PostToolUse, + "postToolUseFailure" => Self::PostToolUseFailure, + "subagentStart" => Self::SubagentStart, + "subagentStop" => Self::SubagentStop, + "beforeShellExecution" => Self::BeforeShellExecution, + "afterShellExecution" => Self::AfterShellExecution, + "beforeMCPExecution" => Self::BeforeMcpExecution, + "afterMCPExecution" => Self::AfterMcpExecution, + "beforeReadFile" => Self::BeforeReadFile, + "afterFileEdit" => Self::AfterFileEdit, + "afterAgentResponse" => Self::AfterAgentResponse, + "afterAgentThought" => Self::AfterAgentThought, + "preCompact" => Self::PreCompact, + "stop" => Self::Stop, + other => Self::Unknown(other.to_string()), + } + } +} + impl AgentCheckpointPreset for CursorPreset { fn run(&self, flags: AgentCheckpointFlags) -> Result { // Parse hook_input JSON to extract workspace_roots and conversation_id @@ -1543,6 +1918,7 @@ impl AgentCheckpointPreset for CursorPreset { GitAiError::PresetError("hook_event_name not found in hook_input".to_string()) })? .to_string(); + let hook_event = CursorHookEvent::parse(&hook_event_name); // Extract model from hook input (Cursor provides this directly) let model = hook_data @@ -1551,34 +1927,6 @@ impl AgentCheckpointPreset for CursorPreset { .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()); - let supported_events = [ - "sessionStart", - "sessionEnd", - "beforeSubmitPrompt", - "preToolUse", - "postToolUse", - "postToolUseFailure", - "subagentStart", - "subagentStop", - "beforeShellExecution", - "afterShellExecution", - "beforeMCPExecution", - "afterMCPExecution", - "beforeReadFile", - "afterFileEdit", - "afterAgentResponse", - "afterAgentThought", - "preCompact", - "stop", - ]; - if !supported_events.contains(&hook_event_name.as_str()) { - return Err(GitAiError::PresetError(format!( - "Invalid hook_event_name: {} for Cursor preset", - hook_event_name - ))); - } - - let hook_source = Some("cursor_hook".to_string()); let mut telemetry_payload = collect_common_hook_telemetry_payload(&hook_data); if let Some(prompt) = hook_data.get("prompt").and_then(|v| v.as_str()) { telemetry_payload.insert( @@ -1613,14 +1961,218 @@ impl AgentCheckpointPreset for CursorPreset { { telemetry_payload.insert("mcp_tool_name".to_string(), tool_name.to_string()); } - if hook_event_name != "beforeSubmitPrompt" && hook_event_name != "afterFileEdit" { - telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); + let telemetry = TelemetryPayloadView::from_payload(&telemetry_payload); + let mut telemetry_events = Vec::new(); + + match &hook_event { + CursorHookEvent::SessionStart => { + telemetry_events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Started, + reason: telemetry.reason.clone(), + source: Some("cursor_hook".to_string()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })) + } + CursorHookEvent::SessionEnd => { + telemetry_events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Ended, + reason: telemetry.reason.clone(), + source: Some("cursor_hook".to_string()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })) + } + CursorHookEvent::BeforeSubmitPrompt => { + telemetry_events.push(AgentTelemetryEvent::Message(AgentMessageTelemetry { + role: MessageRole::Human, + prompt_char_count: telemetry.prompt_char_count, + attachment_count: telemetry.attachment_count, + signal: TelemetrySignal::Explicit, + })); + } + CursorHookEvent::PreToolUse => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Started, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Started, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Inferred, + })); + } + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::PostToolUse => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Ended, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Ended, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Inferred, + })); + } + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::PostToolUseFailure => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Failed, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Explicit, + })); + if payload_has_mcp_context(&telemetry_payload) { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Failed, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Inferred, + })); + } + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::BeforeMcpExecution => { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Started, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + } + CursorHookEvent::AfterMcpExecution => { + telemetry_events.push(AgentTelemetryEvent::McpCall(AgentMcpCallTelemetry { + phase: McpCallPhase::Ended, + mcp_server: telemetry.mcp_server.clone(), + tool_name: telemetry.mcp_tool_name.clone(), + transport: telemetry.mcp_transport.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + } + CursorHookEvent::SubagentStart => { + telemetry_events.push(AgentTelemetryEvent::Subagent(AgentSubagentTelemetry { + phase: SubagentPhase::Started, + subagent_id: telemetry.subagent_id.clone(), + subagent_type: telemetry.subagent_type.clone(), + status: telemetry.status.clone(), + duration_ms: telemetry.duration_ms, + result_char_count: telemetry.result_char_count, + signal: TelemetrySignal::Explicit, + })); + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::SubagentStop => { + telemetry_events.push(AgentTelemetryEvent::Subagent(AgentSubagentTelemetry { + phase: SubagentPhase::Ended, + subagent_id: telemetry.subagent_id.clone(), + subagent_type: telemetry.subagent_type.clone(), + status: telemetry.status.clone(), + duration_ms: telemetry.duration_ms, + result_char_count: telemetry.result_char_count, + signal: TelemetrySignal::Explicit, + })); + } + CursorHookEvent::AfterAgentResponse => { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::AfterAgentThought => { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::Stop => { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CursorHookEvent::BeforeShellExecution + | CursorHookEvent::AfterShellExecution + | CursorHookEvent::BeforeReadFile + | CursorHookEvent::AfterFileEdit + | CursorHookEvent::PreCompact + | CursorHookEvent::Unknown(_) => {} + } + + if let Some(skill_event) = maybe_skill_event(&telemetry_payload) { + telemetry_events.push(skill_event); } - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }; let file_path = hook_data .get("file_path") @@ -1651,7 +2203,7 @@ impl AgentCheckpointPreset for CursorPreset { })? }; - if hook_event_name == "beforeSubmitPrompt" { + if matches!(&hook_event, CursorHookEvent::BeforeSubmitPrompt) { // early return, we're just adding a human checkpoint. return Ok(AgentRunResult { agent_id: AgentId { @@ -1666,13 +2218,12 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, - hook_event_name: Some(hook_event_name), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }); } - if hook_event_name != "afterFileEdit" { + if !matches!(&hook_event, CursorHookEvent::AfterFileEdit) { return Ok(AgentRunResult { agent_id: AgentId { tool: "cursor".to_string(), @@ -1686,9 +2237,10 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, - hook_event_name: Some(hook_event_name), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly, + }, + telemetry_events, }); } @@ -1765,9 +2317,8 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths, will_edit_filepaths: None, dirty_files: None, - hook_event_name: Some(hook_event_name), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }) } } @@ -2064,6 +2615,39 @@ impl CursorPreset { pub struct GithubCopilotPreset; +#[derive(Clone, Debug, PartialEq, Eq)] +enum CopilotHookEvent { + BeforeEdit, + AfterEdit, + SessionStart, + UserPromptSubmit, + PreToolUse, + PostToolUse, + PreCompact, + SubagentStart, + SubagentStop, + Stop, + Unknown(String), +} + +impl CopilotHookEvent { + fn parse(value: &str) -> Self { + match value { + "before_edit" => Self::BeforeEdit, + "after_edit" => Self::AfterEdit, + "SessionStart" => Self::SessionStart, + "UserPromptSubmit" => Self::UserPromptSubmit, + "PreToolUse" => Self::PreToolUse, + "PostToolUse" => Self::PostToolUse, + "PreCompact" => Self::PreCompact, + "SubagentStart" => Self::SubagentStart, + "SubagentStop" => Self::SubagentStop, + "Stop" => Self::Stop, + other => Self::Unknown(other.to_string()), + } + } +} + #[derive(Default)] struct CopilotModelCandidates { request_non_auto_model_id: Option, @@ -2095,42 +2679,49 @@ impl AgentCheckpointPreset for GithubCopilotPreset { .or_else(|| hook_data.get("hookEventName")) .and_then(|v| v.as_str()) .unwrap_or("after_edit"); + let hook_event = CopilotHookEvent::parse(hook_event_name); - if hook_event_name == "before_edit" || hook_event_name == "after_edit" { - return Self::run_legacy_extension_hooks(&hook_data, hook_event_name); + if matches!( + hook_event, + CopilotHookEvent::BeforeEdit | CopilotHookEvent::AfterEdit + ) { + return Self::run_legacy_extension_hooks(&hook_data, &hook_event); } - if Self::is_supported_vscode_hook_event(hook_event_name) { - return Self::run_vscode_native_hooks(&hook_data, hook_event_name); + if Self::is_supported_vscode_hook_event(&hook_event) { + return Self::run_vscode_native_hooks(&hook_data, &hook_event); } - Err(GitAiError::PresetError(format!( - "Invalid hook_event_name: {}. Expected one of 'before_edit', 'after_edit', 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PreCompact', 'SubagentStart', 'SubagentStop', or 'Stop'", - hook_event_name - ))) + Ok(Self::telemetry_only_result( + &hook_event, + "unknown", + "unknown", + None, + Self::dirty_files_from_hook_data(&hook_data), + vec![], + )) } } impl GithubCopilotPreset { - fn is_supported_vscode_hook_event(hook_event_name: &str) -> bool { + fn is_supported_vscode_hook_event(hook_event: &CopilotHookEvent) -> bool { matches!( - hook_event_name, - "SessionStart" - | "UserPromptSubmit" - | "PreToolUse" - | "PostToolUse" - | "PreCompact" - | "SubagentStart" - | "SubagentStop" - | "Stop" + hook_event, + CopilotHookEvent::SessionStart + | CopilotHookEvent::UserPromptSubmit + | CopilotHookEvent::PreToolUse + | CopilotHookEvent::PostToolUse + | CopilotHookEvent::PreCompact + | CopilotHookEvent::SubagentStart + | CopilotHookEvent::SubagentStop + | CopilotHookEvent::Stop ) } fn run_legacy_extension_hooks( hook_data: &serde_json::Value, - hook_event_name: &str, + hook_event: &CopilotHookEvent, ) -> Result { - let hook_source = Some("github_copilot_hook".to_string()); let telemetry_payload_common = collect_common_hook_telemetry_payload(hook_data); let repo_working_dir: String = hook_data @@ -2146,7 +2737,7 @@ impl GithubCopilotPreset { let dirty_files = Self::dirty_files_from_hook_data(hook_data); - if hook_event_name == "before_edit" { + if matches!(hook_event, CopilotHookEvent::BeforeEdit) { let will_edit_filepaths = hook_data .get("will_edit_filepaths") .and_then(|v| v.as_array()) @@ -2172,11 +2763,28 @@ impl GithubCopilotPreset { let mut telemetry_payload = telemetry_payload_common.clone(); telemetry_payload.insert("tool_name".to_string(), "edit_file".to_string()); telemetry_payload.insert("agent_tool".to_string(), "github-copilot".to_string()); - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }; + let telemetry = TelemetryPayloadView::from_payload(&telemetry_payload); + let mut telemetry_events = vec![ + AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Started, + tool_name: Some("edit_file".to_string()), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + }), + AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + }), + ]; + if let Some(skill_event) = maybe_skill_event(&telemetry_payload) { + telemetry_events.push(skill_event); + } return Ok(AgentRunResult { agent_id: AgentId { @@ -2191,9 +2799,8 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: Some(will_edit_filepaths), dirty_files, - hook_event_name: Some(hook_event_name.to_string()), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }); } @@ -2260,11 +2867,28 @@ impl GithubCopilotPreset { let mut telemetry_payload = telemetry_payload_common; telemetry_payload.insert("tool_name".to_string(), "edit_file".to_string()); telemetry_payload.insert("agent_tool".to_string(), "github-copilot".to_string()); - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }; + let telemetry = TelemetryPayloadView::from_payload(&telemetry_payload); + let mut telemetry_events = vec![ + AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Ended, + tool_name: Some("edit_file".to_string()), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Explicit, + }), + AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + }), + ]; + if let Some(skill_event) = maybe_skill_event(&telemetry_payload) { + telemetry_events.push(skill_event); + } Ok(AgentRunResult { agent_id, @@ -2276,17 +2900,15 @@ impl GithubCopilotPreset { edited_filepaths: edited_filepaths.or(detected_edited_filepaths), will_edit_filepaths: None, dirty_files, - hook_event_name: Some(hook_event_name.to_string()), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }) } fn run_vscode_native_hooks( hook_data: &serde_json::Value, - hook_event_name: &str, + hook_event: &CopilotHookEvent, ) -> Result { - let hook_source = Some("github_copilot_hook".to_string()); let cwd = hook_data .get("cwd") .and_then(|v| v.as_str()) @@ -2317,15 +2939,17 @@ impl GithubCopilotPreset { telemetry_payload.insert("tool_name".to_string(), tool_name.to_string()); telemetry_payload.insert("agent_tool".to_string(), "github-copilot".to_string()); - if !matches!(hook_event_name, "PreToolUse" | "PostToolUse") { + if !matches!( + hook_event, + CopilotHookEvent::PreToolUse | CopilotHookEvent::PostToolUse + ) { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &chat_session_id, &model_hint, cwd, dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); } @@ -2333,25 +2957,23 @@ impl GithubCopilotPreset { // Keep telemetry for all tools; only create checkpoints for known edit tools. if !Self::is_supported_vscode_edit_tool_name(tool_name) { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &chat_session_id, &model_hint, cwd, dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); } let Some(cwd) = cwd else { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &chat_session_id, &model_hint, None, dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); }; @@ -2391,13 +3013,12 @@ impl GithubCopilotPreset { if !Self::is_likely_copilot_native_hook(transcript_path.as_deref()) { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &chat_session_id, &model_hint, Some(cwd), dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); } @@ -2447,25 +3068,18 @@ impl GithubCopilotPreset { } } - if hook_event_name == "PreToolUse" { + if matches!(hook_event, CopilotHookEvent::PreToolUse) { if extracted_paths.is_empty() { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &chat_session_id, &model_hint, Some(cwd), dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); } - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }; - return Ok(AgentRunResult { agent_id: AgentId { tool: "human".to_string(), @@ -2479,21 +3093,22 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: Some(extracted_paths), dirty_files, - hook_event_name: Some(hook_event_name.to_string()), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: Self::telemetry_events_for_copilot_event( + hook_event, + &telemetry_payload, + ), }); } let Some(transcript_path) = transcript_path else { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &chat_session_id, &model_hint, Some(cwd), dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); }; @@ -2510,22 +3125,15 @@ impl GithubCopilotPreset { if extracted_paths.is_empty() { return Ok(Self::telemetry_only_result( - hook_event_name, - hook_source, + hook_event, &agent_id.id, &agent_id.model, Some(cwd), dirty_files, - telemetry_payload, + Self::telemetry_events_for_copilot_event(hook_event, &telemetry_payload), )); } - let telemetry_payload = if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }; - Ok(AgentRunResult { agent_id, agent_metadata: Some(agent_metadata), @@ -2535,23 +3143,22 @@ impl GithubCopilotPreset { edited_filepaths: Some(extracted_paths), will_edit_filepaths: None, dirty_files, - hook_event_name: Some(hook_event_name.to_string()), - hook_source, - telemetry_payload, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: Self::telemetry_events_for_copilot_event( + hook_event, + &telemetry_payload, + ), }) } fn telemetry_only_result( - hook_event_name: &str, - hook_source: Option, + hook_event: &CopilotHookEvent, chat_session_id: &str, model: &str, repo_working_dir: Option, dirty_files: Option>, - mut telemetry_payload: HashMap, + telemetry_events: Vec, ) -> AgentRunResult { - telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); - AgentRunResult { agent_id: AgentId { tool: "github-copilot".to_string(), @@ -2565,10 +3172,119 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files, - hook_event_name: Some(hook_event_name.to_string()), - hook_source, - telemetry_payload: Some(telemetry_payload), + checkpoint_execution: CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly, + }, + telemetry_events: if telemetry_events.is_empty() { + Self::telemetry_events_for_copilot_event(hook_event, &HashMap::new()) + } else { + telemetry_events + }, + } + } + + fn telemetry_events_for_copilot_event( + event: &CopilotHookEvent, + payload: &HashMap, + ) -> Vec { + let telemetry = TelemetryPayloadView::from_payload(payload); + let mut events = Vec::new(); + + match event { + CopilotHookEvent::SessionStart => { + events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Started, + reason: telemetry.reason.clone(), + source: Some("github_copilot_hook".to_string()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })) + } + CopilotHookEvent::UserPromptSubmit => { + events.push(AgentTelemetryEvent::Message(AgentMessageTelemetry { + role: MessageRole::Human, + prompt_char_count: telemetry.prompt_char_count, + attachment_count: telemetry.attachment_count, + signal: TelemetrySignal::Explicit, + })); + } + CopilotHookEvent::PreToolUse | CopilotHookEvent::BeforeEdit => { + events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Started, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CopilotHookEvent::PostToolUse | CopilotHookEvent::AfterEdit => { + events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Ended, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Explicit, + })); + events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + CopilotHookEvent::SubagentStart => { + events.push(AgentTelemetryEvent::Subagent(AgentSubagentTelemetry { + phase: SubagentPhase::Started, + subagent_id: telemetry.subagent_id.clone(), + subagent_type: telemetry.subagent_type.clone(), + status: telemetry.status.clone(), + duration_ms: telemetry.duration_ms, + result_char_count: telemetry.result_char_count, + signal: TelemetrySignal::Explicit, + })) + } + CopilotHookEvent::SubagentStop => { + events.push(AgentTelemetryEvent::Subagent(AgentSubagentTelemetry { + phase: SubagentPhase::Ended, + subagent_id: telemetry.subagent_id.clone(), + subagent_type: telemetry.subagent_type.clone(), + status: telemetry.status.clone(), + duration_ms: telemetry.duration_ms, + result_char_count: telemetry.result_char_count, + signal: TelemetrySignal::Explicit, + })) + } + CopilotHookEvent::Stop => { + events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })) + } + CopilotHookEvent::PreCompact | CopilotHookEvent::Unknown(_) => {} + } + + if let Some(skill_event) = maybe_skill_event(payload) { + events.push(skill_event); } + + events } fn dirty_files_from_hook_data( @@ -3176,9 +3892,8 @@ impl AgentCheckpointPreset for DroidPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }); } @@ -3192,9 +3907,8 @@ impl AgentCheckpointPreset for DroidPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }) } } @@ -3952,9 +4666,8 @@ impl AgentCheckpointPreset for AiTabPreset { edited_filepaths: None, will_edit_filepaths, dirty_files, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }); } @@ -3967,9 +4680,8 @@ impl AgentCheckpointPreset for AiTabPreset { edited_filepaths, will_edit_filepaths: None, dirty_files, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }) } } diff --git a/src/commands/checkpoint_agent/agent_v1_preset.rs b/src/commands/checkpoint_agent/agent_v1_preset.rs index 893652137..0d7a737a9 100644 --- a/src/commands/checkpoint_agent/agent_v1_preset.rs +++ b/src/commands/checkpoint_agent/agent_v1_preset.rs @@ -7,7 +7,9 @@ use crate::{ transcript::AiTranscript, working_log::{AgentId, CheckpointKind}, }, - commands::checkpoint_agent::agent_presets::{AgentCheckpointPreset, AgentRunResult}, + commands::checkpoint_agent::agent_presets::{ + AgentCheckpointPreset, AgentRunResult, CheckpointExecution, + }, }; pub struct AgentV1Preset; @@ -71,9 +73,8 @@ impl AgentCheckpointPreset for AgentV1Preset { repo_working_dir: Some(repo_working_dir), edited_filepaths: None, dirty_files, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }), AgentV1Input::AiAgent { edited_filepaths, @@ -96,9 +97,8 @@ impl AgentCheckpointPreset for AgentV1Preset { edited_filepaths, will_edit_filepaths: None, dirty_files, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }), } } diff --git a/src/commands/checkpoint_agent/mod.rs b/src/commands/checkpoint_agent/mod.rs index a1413f086..495124be4 100644 --- a/src/commands/checkpoint_agent/mod.rs +++ b/src/commands/checkpoint_agent/mod.rs @@ -1,3 +1,5 @@ pub mod agent_presets; pub mod agent_v1_preset; pub mod opencode_preset; +pub mod telemetry_events; +pub mod telemetry_payload; diff --git a/src/commands/checkpoint_agent/opencode_preset.rs b/src/commands/checkpoint_agent/opencode_preset.rs index ae9ad7005..9de56c30f 100644 --- a/src/commands/checkpoint_agent/opencode_preset.rs +++ b/src/commands/checkpoint_agent/opencode_preset.rs @@ -4,8 +4,15 @@ use crate::{ working_log::{AgentId, CheckpointKind}, }, commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, + AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, CheckpointExecution, + NoOpReason, }, + commands::checkpoint_agent::telemetry_events::{ + AgentMessageTelemetry, AgentResponseTelemetry, AgentSessionTelemetry, AgentTelemetryEvent, + AgentToolCallTelemetry, MessageRole, ResponsePhase, SessionPhase, TelemetrySignal, + ToolCallPhase, + }, + commands::checkpoint_agent::telemetry_payload::TelemetryPayloadView, error::GitAiError, observability::log_error, }; @@ -17,6 +24,37 @@ use std::path::{Path, PathBuf}; pub struct OpenCodePreset; +#[derive(Clone, Debug, PartialEq, Eq)] +enum OpenCodeHookEvent { + PreToolUse, + PostToolUse, + SessionCreated, + SessionDeleted, + SessionIdle, + MessageUpdated, + MessagePartUpdated, + ToolExecuteBefore, + ToolExecuteAfter, + Unknown(String), +} + +impl OpenCodeHookEvent { + fn parse(value: &str) -> Self { + match value { + "PreToolUse" => Self::PreToolUse, + "PostToolUse" => Self::PostToolUse, + "session.created" => Self::SessionCreated, + "session.deleted" => Self::SessionDeleted, + "session.idle" => Self::SessionIdle, + "message.updated" => Self::MessageUpdated, + "message.part.updated" => Self::MessagePartUpdated, + "tool.execute.before" => Self::ToolExecuteBefore, + "tool.execute.after" => Self::ToolExecuteAfter, + other => Self::Unknown(other.to_string()), + } + } +} + /// Hook input from OpenCode plugin #[derive(Debug, Deserialize)] struct OpenCodeHookInput { @@ -181,15 +219,137 @@ impl AgentCheckpointPreset for OpenCodePreset { .and_then(|ti| ti.file_path) .map(|path| vec![path]); - let hook_source = hook_source.or_else(|| Some("opencode_plugin".to_string())); + let hook_source = hook_source + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("opencode_plugin") + .to_string(); + let hook_event = OpenCodeHookEvent::parse(&hook_event_name); let mut telemetry_payload = telemetry_payload.unwrap_or_default(); if let Some(name) = tool_name { telemetry_payload.insert("tool_name".to_string(), name); } + let telemetry = TelemetryPayloadView::from_payload(&telemetry_payload); + + let role = telemetry_payload.get("role").map(String::as_str); + + let mut telemetry_events = Vec::new(); + match hook_event { + OpenCodeHookEvent::SessionCreated => { + telemetry_events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Started, + reason: telemetry.reason.clone(), + source: Some(hook_source.clone()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })); + } + OpenCodeHookEvent::SessionDeleted => { + telemetry_events.push(AgentTelemetryEvent::Session(AgentSessionTelemetry { + phase: SessionPhase::Ended, + reason: telemetry.reason.clone(), + source: Some(hook_source.clone()), + mode: telemetry.mode.clone(), + duration_ms: telemetry.duration_ms, + signal: TelemetrySignal::Explicit, + })); + } + OpenCodeHookEvent::SessionIdle => { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + OpenCodeHookEvent::MessagePartUpdated => { + if !matches!(role, Some("user" | "human")) + && telemetry.response_char_count.is_some() + { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: None, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + } + OpenCodeHookEvent::MessageUpdated => { + if !matches!(role, Some("user" | "human")) + && telemetry.response_char_count.is_some() + { + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Explicit, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + } + OpenCodeHookEvent::PreToolUse | OpenCodeHookEvent::ToolExecuteBefore => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Started, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: None, + signal: TelemetrySignal::Explicit, + })); + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Started, + reason: telemetry.reason.clone(), + status: None, + response_char_count: None, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + OpenCodeHookEvent::PostToolUse | OpenCodeHookEvent::ToolExecuteAfter => { + telemetry_events.push(AgentTelemetryEvent::ToolCall(AgentToolCallTelemetry { + phase: ToolCallPhase::Ended, + tool_name: telemetry.tool_name.clone(), + tool_use_id: telemetry.tool_use_id.clone(), + duration_ms: telemetry.duration_ms, + failure_type: telemetry.failure_type.clone(), + signal: TelemetrySignal::Explicit, + })); + telemetry_events.push(AgentTelemetryEvent::Response(AgentResponseTelemetry { + phase: ResponsePhase::Ended, + reason: telemetry.reason.clone(), + status: telemetry.status.clone(), + response_char_count: telemetry.response_char_count, + signal: TelemetrySignal::Inferred, + dedupe_key: telemetry.dedupe_key.clone(), + })); + } + OpenCodeHookEvent::Unknown(_) => {} + } - let is_edit_event = hook_event_name == "PreToolUse" || hook_event_name == "PostToolUse"; + if matches!(hook_event, OpenCodeHookEvent::MessageUpdated) + && matches!(role, Some("user" | "human")) + && telemetry.prompt_char_count.is_some() + { + telemetry_events.push(AgentTelemetryEvent::Message(AgentMessageTelemetry { + role: MessageRole::Human, + prompt_char_count: telemetry.prompt_char_count, + attachment_count: telemetry.attachment_count, + signal: TelemetrySignal::Explicit, + })); + } + + let is_edit_event = matches!( + hook_event, + OpenCodeHookEvent::PreToolUse | OpenCodeHookEvent::PostToolUse + ); if !is_edit_event { - telemetry_payload.insert("telemetry_only".to_string(), "1".to_string()); let model = telemetry_payload .get("model") .cloned() @@ -207,9 +367,10 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, - hook_event_name: Some(hook_event_name), - hook_source, - telemetry_payload: Some(telemetry_payload), + checkpoint_execution: CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly, + }, + telemetry_events, }); } @@ -252,7 +413,7 @@ impl AgentCheckpointPreset for OpenCodePreset { } // Check if this is a PreToolUse event (human checkpoint) - if hook_event_name == "PreToolUse" { + if matches!(hook_event, OpenCodeHookEvent::PreToolUse) { return Ok(AgentRunResult { agent_id, agent_metadata: None, @@ -262,13 +423,8 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, - hook_event_name: Some(hook_event_name), - hook_source, - telemetry_payload: if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }); } @@ -282,13 +438,8 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, - hook_event_name: Some(hook_event_name), - hook_source, - telemetry_payload: if telemetry_payload.is_empty() { - None - } else { - Some(telemetry_payload) - }, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events, }) } } diff --git a/src/commands/checkpoint_agent/telemetry_events.rs b/src/commands/checkpoint_agent/telemetry_events.rs new file mode 100644 index 000000000..bc0d2ab8b --- /dev/null +++ b/src/commands/checkpoint_agent/telemetry_events.rs @@ -0,0 +1,129 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TelemetrySignal { + Explicit, + Inferred, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionPhase { + Started, + Ended, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MessageRole { + Human, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResponsePhase { + Started, + Ended, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ToolCallPhase { + Started, + Ended, + Failed, + PermissionRequested, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum McpCallPhase { + Started, + Ended, + Failed, + PermissionRequested, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SubagentPhase { + Started, + Ended, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SkillDetectionMethod { + Explicit, + InferredPrompt, + InferredTool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentSessionTelemetry { + pub phase: SessionPhase, + pub reason: Option, + pub source: Option, + pub mode: Option, + pub duration_ms: Option, + pub signal: TelemetrySignal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentMessageTelemetry { + pub role: MessageRole, + pub prompt_char_count: Option, + pub attachment_count: Option, + pub signal: TelemetrySignal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentResponseTelemetry { + pub phase: ResponsePhase, + pub reason: Option, + pub status: Option, + pub response_char_count: Option, + pub signal: TelemetrySignal, + pub dedupe_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentToolCallTelemetry { + pub phase: ToolCallPhase, + pub tool_name: Option, + pub tool_use_id: Option, + pub duration_ms: Option, + pub failure_type: Option, + pub signal: TelemetrySignal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentMcpCallTelemetry { + pub phase: McpCallPhase, + pub mcp_server: Option, + pub tool_name: Option, + pub transport: Option, + pub duration_ms: Option, + pub failure_type: Option, + pub signal: TelemetrySignal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentSkillUsageTelemetry { + pub skill_name: String, + pub detection_method: SkillDetectionMethod, + pub signal: TelemetrySignal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentSubagentTelemetry { + pub phase: SubagentPhase, + pub subagent_id: Option, + pub subagent_type: Option, + pub status: Option, + pub duration_ms: Option, + pub result_char_count: Option, + pub signal: TelemetrySignal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentTelemetryEvent { + Session(AgentSessionTelemetry), + Message(AgentMessageTelemetry), + Response(AgentResponseTelemetry), + ToolCall(AgentToolCallTelemetry), + McpCall(AgentMcpCallTelemetry), + SkillUsage(AgentSkillUsageTelemetry), + Subagent(AgentSubagentTelemetry), +} diff --git a/src/commands/checkpoint_agent/telemetry_payload.rs b/src/commands/checkpoint_agent/telemetry_payload.rs new file mode 100644 index 000000000..b17151378 --- /dev/null +++ b/src/commands/checkpoint_agent/telemetry_payload.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +#[derive(Clone, Debug, Default)] +pub struct TelemetryPayloadView { + pub duration_ms: Option, + pub tool_name: Option, + pub tool_use_id: Option, + pub failure_type: Option, + pub mcp_server: Option, + pub mcp_transport: Option, + pub mcp_tool_name: Option, + pub status: Option, + pub reason: Option, + pub subagent_id: Option, + pub subagent_type: Option, + pub result_char_count: Option, + pub response_char_count: Option, + pub prompt_char_count: Option, + pub attachment_count: Option, + pub mode: Option, + pub dedupe_key: Option, +} + +impl TelemetryPayloadView { + pub fn from_payload(payload: &HashMap) -> Self { + Self::from_payload_with_dedupe_fallback(payload, None) + } + + pub fn from_payload_with_dedupe_fallback( + payload: &HashMap, + fallback: Option<&str>, + ) -> Self { + Self { + duration_ms: parse_u64(payload, "duration_ms"), + tool_name: str_field(payload, "tool_name"), + tool_use_id: str_field(payload, "tool_use_id"), + failure_type: str_field(payload, "failure_type"), + mcp_server: str_field(payload, "mcp_server"), + mcp_transport: str_field(payload, "mcp_transport"), + mcp_tool_name: str_field(payload, "mcp_tool_name") + .or_else(|| str_field(payload, "tool_name")), + status: str_field(payload, "status"), + reason: str_field(payload, "reason"), + subagent_id: str_field(payload, "subagent_id"), + subagent_type: str_field(payload, "subagent_type"), + result_char_count: parse_u32(payload, "result_char_count"), + response_char_count: parse_u32(payload, "response_char_count"), + prompt_char_count: parse_u32(payload, "prompt_char_count"), + attachment_count: parse_u32(payload, "attachment_count"), + mode: str_field(payload, "mode"), + dedupe_key: payload + .get("generation_id") + .or_else(|| payload.get("tool_use_id")) + .or_else(|| payload.get("message_id")) + .map(String::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .or(fallback) + .map(str::to_string), + } + } +} + +fn str_field(payload: &HashMap, key: &str) -> Option { + payload + .get(key) + .map(String::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn parse_u32(payload: &HashMap, key: &str) -> Option { + payload + .get(key) + .map(String::as_str) + .map(str::trim) + .and_then(|v| v.parse::().ok()) +} + +fn parse_u64(payload: &HashMap, key: &str) -> Option { + payload + .get(key) + .map(String::as_str) + .map(str::trim) + .and_then(|v| v.parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::TelemetryPayloadView; + use std::collections::HashMap; + + #[test] + fn test_from_payload_trims_and_discards_empty_fields() { + let mut payload = HashMap::new(); + payload.insert("tool_name".to_string(), " ".to_string()); + payload.insert("reason".to_string(), " denied ".to_string()); + payload.insert("duration_ms".to_string(), " 42 ".to_string()); + payload.insert("generation_id".to_string(), " ".to_string()); + + let view = TelemetryPayloadView::from_payload_with_dedupe_fallback(&payload, Some("fb")); + + assert_eq!(view.tool_name, None); + assert_eq!(view.reason.as_deref(), Some("denied")); + assert_eq!(view.duration_ms, Some(42)); + assert_eq!(view.dedupe_key.as_deref(), Some("fb")); + } +} diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index d43c3f7c0..4b45c6321 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -6,8 +6,9 @@ use crate::authorship::stats::stats_command; use crate::authorship::working_log::{AgentId, CheckpointKind}; use crate::commands; use crate::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, AiTabPreset, ClaudePreset, - CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, GithubCopilotPreset, + AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, AiTabPreset, CheckpointExecution, + ClaudePreset, CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, + GithubCopilotPreset, }; use crate::commands::checkpoint_agent::agent_v1_preset::AgentV1Preset; use crate::commands::checkpoint_agent::opencode_preset::OpenCodePreset; @@ -524,9 +525,8 @@ fn handle_checkpoint(args: &[String]) { edited_filepaths, will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }); } _ => {} @@ -775,9 +775,8 @@ fn handle_checkpoint(args: &[String]) { edited_filepaths: None, repo_working_dir: Some(effective_working_dir), dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }); } @@ -1265,7 +1264,7 @@ fn emit_no_repo_agent_metrics(agent_run_result: Option<&AgentRunResult>) { crate::metrics::record(values, attrs.clone()); } - commands::checkpoint::emit_agent_hook_telemetry(Some(result), attrs); + commands::checkpoint::emit_agent_telemetry_events(Some(result), attrs); observability::spawn_background_flush(); } diff --git a/src/git/test_utils/mod.rs b/src/git/test_utils/mod.rs index cf3cc6c29..218b28d0f 100644 --- a/src/git/test_utils/mod.rs +++ b/src/git/test_utils/mod.rs @@ -439,7 +439,9 @@ impl TmpRepo { ) -> Result<(usize, usize, usize), GitAiError> { use crate::authorship::transcript::AiTranscript; use crate::authorship::working_log::AgentId; - use crate::commands::checkpoint_agent::agent_presets::AgentRunResult; + use crate::commands::checkpoint_agent::agent_presets::{ + AgentRunResult, CheckpointExecution, + }; // Use a deterministic but unique session ID based on agent_name // For common agent names (Claude, GPT-4), use fixed ID for backwards compat @@ -473,9 +475,8 @@ impl TmpRepo { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, - hook_event_name: None, - hook_source: None, - telemetry_payload: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: vec![], }; checkpoint( diff --git a/src/metrics/attrs.rs b/src/metrics/attrs.rs index 6bb0d4414..714ee90e3 100644 --- a/src/metrics/attrs.rs +++ b/src/metrics/attrs.rs @@ -187,6 +187,7 @@ impl EventAttributes { } // Builder methods for hook_event_name + #[allow(dead_code)] pub fn hook_event_name(mut self, value: impl Into) -> Self { self.hook_event_name = Some(Some(value.into())); self @@ -199,6 +200,7 @@ impl EventAttributes { } // Builder methods for hook_source + #[allow(dead_code)] pub fn hook_source(mut self, value: impl Into) -> Self { self.hook_source = Some(Some(value.into())); self diff --git a/tests/claude_code.rs b/tests/claude_code.rs index 48174b92e..8fd6dc569 100644 --- a/tests/claude_code.rs +++ b/tests/claude_code.rs @@ -4,8 +4,11 @@ mod test_utils; use git_ai::authorship::transcript::Message; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, ClaudePreset, extract_plan_from_tool_use, - is_plan_file_path, + AgentCheckpointFlags, AgentCheckpointPreset, CheckpointExecution, ClaudePreset, NoOpReason, + extract_plan_from_tool_use, is_plan_file_path, +}; +use git_ai::commands::checkpoint_agent::telemetry_events::{ + AgentTelemetryEvent, SessionPhase, TelemetrySignal, }; use serde_json::json; use std::collections::HashMap; @@ -120,19 +123,19 @@ fn test_claude_preset_session_start_without_transcript_is_telemetry_only() { assert_eq!(result.agent_id.tool, "claude"); assert_eq!(result.agent_id.id, "session-123"); assert_eq!( - result.hook_event_name.as_deref(), - Some("SessionStart"), - "hook event name should be normalized onto AgentRunResult" - ); - assert_eq!(result.hook_source.as_deref(), Some("claude_hook")); - assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert!(result.telemetry_events.iter().any(|event| { + matches!( + event, + AgentTelemetryEvent::Session(session) + if session.phase == SessionPhase::Started + && session.signal == TelemetrySignal::Explicit + ) + })); } #[test] diff --git a/tests/codex.rs b/tests/codex.rs index f92cdaf09..faf4131ff 100644 --- a/tests/codex.rs +++ b/tests/codex.rs @@ -5,7 +5,10 @@ mod test_utils; use git_ai::authorship::transcript::Message; use git_ai::authorship::working_log::CheckpointKind; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, CodexPreset, + AgentCheckpointFlags, AgentCheckpointPreset, CheckpointExecution, CodexPreset, +}; +use git_ai::commands::checkpoint_agent::telemetry_events::{ + AgentTelemetryEvent, MessageRole, ResponsePhase, SessionPhase, TelemetrySignal, }; use serde_json::json; use std::fs; @@ -96,18 +99,37 @@ fn test_codex_preset_legacy_hook_input() { .is_some(), "transcript_path should be persisted for commit-time resync" ); - assert_eq!(result.hook_source.as_deref(), Some("codex_notify")); - assert_eq!( - result.hook_event_name.as_deref(), - Some("agent-turn-complete") + assert_eq!(result.checkpoint_execution, CheckpointExecution::Run); + assert!( + result.telemetry_events.iter().any(|event| matches!( + event, + AgentTelemetryEvent::Session(session) + if session.phase == SessionPhase::Started + && session.signal == TelemetrySignal::Inferred + )), + "Codex should emit inferred session-start telemetry" ); assert_eq!( result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("prompt_char_count")) - .map(String::as_str), - Some("20") + .telemetry_events + .iter() + .find_map(|event| match event { + AgentTelemetryEvent::Message(message) if message.role == MessageRole::Human => { + message.prompt_char_count + } + _ => None, + }), + Some(20), + "Prompt char count should be reflected in human message telemetry" + ); + assert!( + result.telemetry_events.iter().any(|event| matches!( + event, + AgentTelemetryEvent::Response(response) + if response.phase == ResponsePhase::Ended + && response.signal == TelemetrySignal::Explicit + )), + "agent-turn-complete should emit explicit response-ended telemetry" ); } @@ -153,8 +175,16 @@ fn test_codex_preset_structured_hook_input() { result.transcript.is_some(), "AI checkpoint should include transcript" ); - assert_eq!(result.hook_source.as_deref(), Some("codex_notify")); - assert_eq!(result.hook_event_name.as_deref(), Some("after_agent")); + assert_eq!(result.checkpoint_execution, CheckpointExecution::Run); + assert!( + result.telemetry_events.iter().any(|event| matches!( + event, + AgentTelemetryEvent::Response(response) + if response.phase == ResponsePhase::Ended + && response.signal == TelemetrySignal::Explicit + )), + "after_agent should emit explicit response-ended telemetry" + ); } #[test] diff --git a/tests/cursor.rs b/tests/cursor.rs index d6127c090..2af2af2ef 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -328,7 +328,10 @@ fn test_cursor_preset_human_checkpoint_no_filepath() { fn test_cursor_preset_session_start_telemetry_only() { use git_ai::authorship::working_log::CheckpointKind; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + AgentCheckpointFlags, AgentCheckpointPreset, CheckpointExecution, CursorPreset, NoOpReason, + }; + use git_ai::commands::checkpoint_agent::telemetry_events::{ + AgentTelemetryEvent, SessionPhase, TelemetrySignal, }; let hook_input = r##"{ @@ -349,23 +352,30 @@ fn test_cursor_preset_session_start_telemetry_only() { .expect("Should parse sessionStart hook payload"); assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); - assert_eq!(result.hook_event_name.as_deref(), Some("sessionStart")); - assert_eq!(result.hook_source.as_deref(), Some("cursor_hook")); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert_eq!( + result.telemetry_events.len(), + 1, + "sessionStart should emit exactly one telemetry event" + ); + assert!(result.telemetry_events.iter().any(|event| matches!( + event, + AgentTelemetryEvent::Session(session) + if session.phase == SessionPhase::Started + && session.signal == TelemetrySignal::Explicit + ))); } #[test] fn test_cursor_preset_precompact_telemetry_only() { use git_ai::authorship::working_log::CheckpointKind; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + AgentCheckpointFlags, AgentCheckpointPreset, CheckpointExecution, CursorPreset, NoOpReason, }; let hook_input = r##"{ @@ -386,14 +396,15 @@ fn test_cursor_preset_precompact_telemetry_only() { .expect("Should parse preCompact hook payload"); assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); - assert_eq!(result.hook_event_name.as_deref(), Some("preCompact")); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } + ); + assert!( + result.telemetry_events.is_empty(), + "preCompact currently emits no normalized telemetry events" ); } @@ -401,7 +412,7 @@ fn test_cursor_preset_precompact_telemetry_only() { fn test_cursor_preset_before_read_file_telemetry_only() { use git_ai::authorship::working_log::CheckpointKind; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, CursorPreset, + AgentCheckpointFlags, AgentCheckpointPreset, CheckpointExecution, CursorPreset, NoOpReason, }; let hook_input = r##"{ @@ -422,14 +433,15 @@ fn test_cursor_preset_before_read_file_telemetry_only() { .expect("Should parse beforeReadFile hook payload"); assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); - assert_eq!(result.hook_event_name.as_deref(), Some("beforeReadFile")); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } + ); + assert!( + result.telemetry_events.is_empty(), + "beforeReadFile should be telemetry-only without normalized events" ); } diff --git a/tests/github_copilot.rs b/tests/github_copilot.rs index 671fc173e..ab3363465 100644 --- a/tests/github_copilot.rs +++ b/tests/github_copilot.rs @@ -1,7 +1,12 @@ mod test_utils; use git_ai::authorship::transcript::Message; -use git_ai::commands::checkpoint_agent::agent_presets::GithubCopilotPreset; +use git_ai::commands::checkpoint_agent::agent_presets::{ + CheckpointExecution, GithubCopilotPreset, NoOpReason, +}; +use git_ai::commands::checkpoint_agent::telemetry_events::{ + AgentTelemetryEvent, MessageRole, SessionPhase, SubagentPhase, ToolCallPhase, +}; use serde_json::json; use std::{fs, io::Write}; use test_utils::{fixture_path, load_fixture}; @@ -562,13 +567,18 @@ fn test_copilot_preset_invalid_hook_event_name() { let preset = GithubCopilotPreset; let result = preset.run(flags); - // Should fail with invalid hook event name - assert!(result.is_err()); + // Unknown events should fail-open as telemetry-only no-op. + assert!(result.is_ok()); + let run_result = result.unwrap(); + assert_eq!( + run_result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } + ); assert!( - result - .unwrap_err() - .to_string() - .contains("Invalid hook_event_name") + run_result.telemetry_events.is_empty(), + "Unknown hook events should not emit normalized telemetry" ); } @@ -1309,15 +1319,19 @@ fn test_copilot_preset_vscode_non_edit_tool_is_filtered() { ); assert_eq!(result.will_edit_filepaths, None); assert_eq!(result.edited_filepaths, None); - assert_eq!(result.hook_event_name.as_deref(), Some("PreToolUse")); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert!(result.telemetry_events.iter().any(|event| { + matches!( + event, + AgentTelemetryEvent::ToolCall(tool) + if tool.phase == ToolCallPhase::Started + ) + })); } #[test] @@ -1341,17 +1355,21 @@ fn test_copilot_preset_vscode_session_start_is_telemetry_only() { }) .expect("SessionStart should be accepted"); - assert_eq!(result.hook_event_name.as_deref(), Some("SessionStart")); assert_eq!(result.agent_id.tool, "github-copilot"); assert_eq!(result.agent_id.id, "copilot-session-start"); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert!(result.telemetry_events.iter().any(|event| { + matches!( + event, + AgentTelemetryEvent::Session(session) + if session.phase == SessionPhase::Started + ) + })); } #[test] @@ -1374,23 +1392,20 @@ fn test_copilot_preset_vscode_user_prompt_submit_is_telemetry_only() { }) .expect("UserPromptSubmit should be accepted"); - assert_eq!(result.hook_event_name.as_deref(), Some("UserPromptSubmit")); - assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("prompt_char_count")) - .map(String::as_str), - Some("29") - ); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert!(result.telemetry_events.iter().any(|event| { + matches!( + event, + AgentTelemetryEvent::Message(message) + if message.role == MessageRole::Human + && message.prompt_char_count == Some(29) + ) + })); } #[test] @@ -1414,31 +1429,21 @@ fn test_copilot_preset_vscode_subagent_events_are_telemetry_only() { }) .expect("SubagentStart should be accepted"); - assert_eq!(result.hook_event_name.as_deref(), Some("SubagentStart")); - assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("subagent_id")) - .map(String::as_str), - Some("subagent-123") - ); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("subagent_type")) - .map(String::as_str), - Some("Plan") - ); - assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert!(result.telemetry_events.iter().any(|event| { + matches!( + event, + AgentTelemetryEvent::Subagent(subagent) + if subagent.phase == SubagentPhase::Started + && subagent.subagent_id.as_deref() == Some("subagent-123") + && subagent.subagent_type.as_deref() == Some("Plan") + ) + })); } #[test] @@ -1467,15 +1472,19 @@ fn test_copilot_preset_vscode_claude_transcript_path_is_telemetry_only() { .run(flags) .expect("Claude-like transcript path should fall back to telemetry-only"); - assert_eq!(result.hook_event_name.as_deref(), Some("PostToolUse")); - assert!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str) - == Some("1") + assert_eq!( + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } ); + assert!(result.telemetry_events.iter().any(|event| { + matches!( + event, + AgentTelemetryEvent::ToolCall(tool) + if tool.phase == ToolCallPhase::Ended + ) + })); } #[test] diff --git a/tests/opencode.rs b/tests/opencode.rs index e4691427a..8489f170b 100644 --- a/tests/opencode.rs +++ b/tests/opencode.rs @@ -5,9 +5,12 @@ mod test_utils; use git_ai::authorship::transcript::Message; use git_ai::authorship::working_log::CheckpointKind; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, + AgentCheckpointFlags, AgentCheckpointPreset, CheckpointExecution, NoOpReason, }; use git_ai::commands::checkpoint_agent::opencode_preset::OpenCodePreset; +use git_ai::commands::checkpoint_agent::telemetry_events::{ + AgentTelemetryEvent, SessionPhase, TelemetrySignal, +}; use serde_json::json; use std::fs; use test_utils::fixture_path; @@ -354,16 +357,26 @@ fn test_opencode_preset_session_created_is_telemetry_only() { result.transcript.is_none(), "Telemetry-only events should skip transcript parsing" ); - assert_eq!(result.hook_event_name.as_deref(), Some("session.created")); - assert_eq!(result.hook_source.as_deref(), Some("opencode_plugin")); assert_eq!( - result - .telemetry_payload - .as_ref() - .and_then(|m| m.get("telemetry_only")) - .map(String::as_str), - Some("1") + result.checkpoint_execution, + CheckpointExecution::NoOp { + reason: NoOpReason::TelemetryOnly + } + ); + assert_eq!(result.telemetry_events.len(), 1); + assert!(result.telemetry_events.iter().any(|event| matches!( + event, + AgentTelemetryEvent::Session(session) + if session.phase == SessionPhase::Started + && session.signal == TelemetrySignal::Explicit + ))); + assert_eq!( + result.repo_working_dir.as_deref(), + Some("/Users/test/project") ); + assert_eq!(result.agent_id.id, "test-session-123"); + assert_eq!(result.agent_id.tool, "opencode"); + assert_eq!(result.agent_id.model, "unknown"); } #[test] From 24884da5c83a12b73afc8f64955c71545236e90e Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 28 Feb 2026 23:57:08 -0500 Subject: [PATCH 09/11] Fix Amp preset AgentRunResult fields --- src/commands/checkpoint_agent/amp_preset.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/checkpoint_agent/amp_preset.rs b/src/commands/checkpoint_agent/amp_preset.rs index 933e9830b..d0752407a 100644 --- a/src/commands/checkpoint_agent/amp_preset.rs +++ b/src/commands/checkpoint_agent/amp_preset.rs @@ -4,7 +4,7 @@ use crate::{ working_log::{AgentId, CheckpointKind}, }, commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, + AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, CheckpointExecution, }, error::GitAiError, observability::log_error, @@ -151,6 +151,8 @@ impl AgentCheckpointPreset for AmpPreset { edited_filepaths: None, will_edit_filepaths: file_paths, dirty_files: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: Vec::new(), }); } @@ -184,6 +186,8 @@ impl AgentCheckpointPreset for AmpPreset { edited_filepaths: file_paths, will_edit_filepaths: None, dirty_files: None, + checkpoint_execution: CheckpointExecution::Run, + telemetry_events: Vec::new(), }) } } From 33f0fcfc99c7de661e48addad9e05c2781da3152 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 28 Feb 2026 23:59:56 -0500 Subject: [PATCH 10/11] Fix clippy redundant local in telemetry emitter --- src/commands/checkpoint.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index 6d49417fe..32e1e405e 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -183,7 +183,6 @@ pub(crate) fn emit_agent_telemetry_events( return; } - let attrs = attrs; let now_ts = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() From 435d3c8f719f0283d2739db1384765afc3d628b9 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sun, 1 Mar 2026 00:04:08 -0500 Subject: [PATCH 11/11] Align copilot unknown-hook test with fail-open behavior --- tests/agent_presets_comprehensive.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/agent_presets_comprehensive.rs b/tests/agent_presets_comprehensive.rs index 01ad9861d..4b0d67010 100644 --- a/tests/agent_presets_comprehensive.rs +++ b/tests/agent_presets_comprehensive.rs @@ -4,8 +4,8 @@ mod test_utils; use git_ai::authorship::working_log::CheckpointKind; use git_ai::commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, AiTabPreset, ClaudePreset, CodexPreset, - ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, GithubCopilotPreset, + AgentCheckpointFlags, AgentCheckpointPreset, AiTabPreset, CheckpointExecution, ClaudePreset, + CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, GithubCopilotPreset, }; use git_ai::commands::checkpoint_agent::amp_preset::AmpPreset; use git_ai::error::GitAiError; @@ -743,18 +743,18 @@ fn test_github_copilot_preset_invalid_hook_event_name() { }) .to_string(); - let result = preset.run(AgentCheckpointFlags { - hook_input: Some(hook_input), - }); - - assert!(result.is_err()); - match result { - Err(GitAiError::PresetError(msg)) => { - assert!(msg.contains("Invalid hook_event_name")); - assert!(msg.contains("before_edit") || msg.contains("after_edit")); - } - _ => panic!("Expected PresetError for invalid hook_event_name"), - } + let result = preset + .run(AgentCheckpointFlags { + hook_input: Some(hook_input), + }) + .expect("unknown copilot hook should fail-open to telemetry no-op"); + + assert!(matches!( + result.checkpoint_execution, + CheckpointExecution::NoOp { .. } + )); + assert_eq!(result.edited_filepaths, None); + assert_eq!(result.will_edit_filepaths, None); } // ==============================================================================