All files / main event-normalizer.ts

81.18% Statements 151/186
72.3% Branches 47/65
100% Functions 6/6
81.18% Lines 151/186

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 2561x           1x         1x               1x 7x 5x 5x 7x 7x 7x 7x     1x 9x 9x                                 1x 13x 13x 13x   13x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x   9x 4x 4x   4x   4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x   13x 3x 3x 1x 2x 1x 1x 3x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x   13x 1x 1x         1x 1x 1x               1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x             13x                             1x 3x 3x 3x           1x 5x                                           1x 8x 8x 2x 2x 8x 8x     8x 6x 6x 6x 6x 6x           8x  
import type { AgentEvent } from "../shared/types";
import { logger } from "./logger";
 
// Dedupe unknown payload-type warnings so a repeated benign event type
// (e.g. `attachment`, `custom-title`) does not flood the log. Each distinct
// type is warned once at INFO level, then silenced.
const warnedTypes = new Set<string>();
 
// Per-session monotonic counter used as a fallback synthetic subagent id
// when a `Task`/`Agent` tool_use payload is missing its tool_use_id. The id
// only needs to be unique within the session for the lifetime of the process.
const fallbackSubagentCounter = new Map<string, number>();
 
/**
 * Returns the id we use to represent a subagent spawned by a `Task` or `Agent`
 * tool call from the parent session. Prefers the real `tool_use_id` so pre /
 * post events can be correlated; falls back to a monotonic per-session counter
 * when the id is missing.
 */
export function subagentIdFor(sessionId: string, toolUseId: string | undefined): string {
  if (typeof toolUseId === "string" && toolUseId.length > 0) {
    return `${sessionId}:${toolUseId}`;
  }
  const next = (fallbackSubagentCounter.get(sessionId) ?? 0) + 1;
  fallbackSubagentCounter.set(sessionId, next);
  return `${sessionId}:sub-${next}`;
}
 
/** True if `tool` is the name Claude Code uses to dispatch a subagent. */
export function isSubagentDispatchTool(tool: string | undefined | null): boolean {
  return tool === "Task" || tool === "Agent";
}
 
/**
 * Normalizes a single parsed Claude Code JSONL line into one or more
 * `AgentEvent`s. Most lines produce exactly one event; `Task` / `Agent` tool
 * calls produce two (the parent's pre/post-tool-use event AND a synthetic
 * subagent-start / subagent-end so the visualization can render a character
 * for the spawned subagent).
 *
 * Returning `AgentEvent[]` keeps the call-site contract simple: always a list,
 * possibly empty. Callers that previously treated a single event as a scalar
 * just iterate.
 *
 * JSONL lines are externally produced and schema-less from our perspective, so
 * `any` is used for the parsed-JSON input. We narrow into the typed AgentEvent.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function normalizeJsonlEvents(raw: any, rawLine: string): AgentEvent[] {
  if (!raw?.sessionId) return [];
  const timestamp = raw.timestamp ? Date.parse(raw.timestamp) : Date.now();
  const sessionId: string = raw.sessionId;
 
  if (raw.type === "user") {
    const excerpt = extractText(raw.message?.content)?.slice(0, 500);
    logger.debug("normalizeJsonlEvents produced user-message", { sessionId });
    return [
      {
        sessionId,
        agentId: sessionId,
        kind: "main",
        timestamp,
        type: "user-message",
        rawLine,
        ...(excerpt !== undefined ? { messageExcerpt: excerpt } : {})
      }
    ];
  }
 
  if (raw.type === "assistant") {
    const content = raw.message?.content;
    const toolUse = Array.isArray(content)
      ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
        content.find((p: any) => p?.type === "tool_use")
      : null;
    if (toolUse) {
      const toolName: string = toolUse.name;
      const parentEvent: AgentEvent = {
        sessionId,
        agentId: sessionId,
        kind: "main",
        timestamp,
        type: "pre-tool-use",
        toolName,
        toolArgsSummary: summarizeArgs(toolName, toolUse.input),
        rawLine
      };
      if (isSubagentDispatchTool(toolName)) {
        const subagentId = subagentIdFor(sessionId, toolUse.id);
        const subagentStart: AgentEvent = {
          sessionId,
          agentId: subagentId,
          parentAgentId: sessionId,
          kind: "subagent",
          timestamp,
          type: "subagent-start",
          rawLine
        };
        logger.debug("normalizeJsonlEvents produced Task pre-tool-use + subagent-start", {
          sessionId,
          subagentId
        });
        return [parentEvent, subagentStart];
      }
      return [parentEvent];
    }
    const excerpt = extractText(content)?.slice(0, 500);
    return [
      {
        sessionId,
        agentId: sessionId,
        kind: "main",
        timestamp,
        type: "assistant-message",
        rawLine,
        ...(excerpt !== undefined ? { messageExcerpt: excerpt } : {})
      }
    ];
  }
 
  if (raw.type === "custom-title" || raw.type === "summary") {
    const title =
      typeof raw.title === "string"
        ? raw.title
        : typeof raw.summary === "string"
          ? raw.summary
          : undefined;
    if (title) {
      return [
        {
          sessionId,
          agentId: sessionId,
          kind: "main",
          timestamp,
          type: "session-title",
          sessionTitle: title,
          rawLine
        }
      ];
    }
    return [];
  }
 
  if (raw.type === "tool_result" || raw.type === "user-tool-result") {
    const summary = extractText(raw.toolUseResult ?? raw.content)?.slice(0, 200);
    logger.debug("normalizeJsonlEvents produced post-tool-use", { sessionId });
    // `tool_result` payloads from Claude Code carry `tool_use_id` that maps
    // back to the originating `tool_use` block on the previous assistant line.
    // We use it both to identify a subagent completion and to keep the pre /
    // post pair linked even when multiple tools are in flight.
    const toolUseId: string | undefined =
      typeof raw.tool_use_id === "string"
        ? raw.tool_use_id
        : typeof raw.toolUseResult?.tool_use_id === "string"
          ? raw.toolUseResult.tool_use_id
          : undefined;
    // We cannot tell from the tool_result alone whether the original tool was
    // `Task`, so we emit both the parent post-tool-use and a speculative
    // subagent-end whenever we have a tool_use_id. If no subagent with that
    // id exists the store silently ignores the end event.
    const parentEvent: AgentEvent = {
      sessionId,
      agentId: sessionId,
      kind: "main",
      timestamp,
      type: "post-tool-use",
      rawLine,
      ...(summary !== undefined ? { resultSummary: summary } : {})
    };
    if (toolUseId !== undefined) {
      const subagentEnd: AgentEvent = {
        sessionId,
        agentId: subagentIdFor(sessionId, toolUseId),
        parentAgentId: sessionId,
        kind: "subagent",
        timestamp,
        type: "subagent-end",
        rawLine
      };
      return [parentEvent, subagentEnd];
    }
    return [parentEvent];
  }
 
  // Unknown / unhandled payload type. Benign - Claude Code emits many event
  // kinds we do not visualise. Log each distinct type exactly once at INFO so
  // we can see the vocabulary without flooding the file on busy sessions.
  if (typeof raw.type === "string" && !warnedTypes.has(raw.type)) {
    warnedTypes.add(raw.type);
    logger.info("normalizeJsonlEvents skipping unhandled payload type (logged once)", {
      payloadType: raw.type
    });
  }
  return [];
}
 
/**
 * Back-compat single-event wrapper. Returns the first event emitted for a
 * given raw line, or `null` if the line produced none. New call sites should
 * prefer `normalizeJsonlEvents` so they see synthetic subagent events too.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function normalizeJsonlEvent(raw: any, rawLine: string): AgentEvent | null {
  const events = normalizeJsonlEvents(raw, rawLine);
  return events[0] ?? null;
}
 
/**
 * Extracts a text excerpt from a Claude Code message `content` field.
 * Supports string content and arrays of `{type, text}` blocks.
 */
export function extractText(content: unknown): string | undefined {
  if (typeof content === "string") return content;
  if (Array.isArray(content)) {
    return content
      .map((p: unknown) => {
        if (typeof p === "string") return p;
        if (p && typeof p === "object" && "text" in p) {
          const t = (p as { text?: unknown }).text;
          return typeof t === "string" ? t : "";
        }
        return "";
      })
      .filter(Boolean)
      .join(" ");
  }
  return undefined;
}
 
/**
 * Condenses tool-call arguments into a short human-readable string for
 * tooltips and timeline entries. Shared between the JSONL watcher and the hook server.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function summarizeArgs(tool: string, input: any): string {
  if (!input) return "";
  if (tool === "Read" || tool === "Edit" || tool === "Write") {
    return String(input.file_path ?? "");
  }
  if (tool === "Bash") return String(input.command ?? "").slice(0, 80);
  if (tool === "Grep" || tool === "Glob") {
    return String(input.pattern ?? input.path ?? "");
  }
  if (tool === "Task" || tool === "Agent") {
    const subtype = typeof input.subagent_type === "string" ? input.subagent_type : "";
    const prompt = typeof input.prompt === "string" ? input.prompt : "";
    const head = subtype ? `${subtype}: ` : "";
    return (head + prompt).slice(0, 80);
  }
  try {
    return JSON.stringify(input).slice(0, 80);
  } catch {
    return "";
  }
}