All files / src/utils sessionEvents.ts

96.25% Statements 77/80
94.44% Branches 51/54
100% Functions 7/7
97.14% Lines 68/70

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                              2x 2x 5x   5x 1x   4x     2x                       22x 12x   10x 10x 4x   6x 6x 6x                           6x 1x   5x 5x 2x   3x 3x 3x                             49x 12x   37x         15x 15x 34x 19x 19x             15x 15x 34x 18x 18x 15x 15x 15x                 15x 15x 34x 13x 13x 10x 10x 10x           10x 10x 2x 2x 2x 1x             10x       15x 34x 18x 18x             15x 24x       8x 8x 5x 4x 4x     8x 4x       15x    
import type { SessionEvent } from "../hooks/types.js";
 
/** Session event augmented with optional tool_use context for paired tool results. */
export type DisplayEvent = SessionEvent & {
  toolUseCtx?: { tool: string; args: unknown; detailedResult?: string };
  /**
   * True when a tool_use event has no matching tool_result but subsequent events
   * prove the tool completed (e.g. Claude Code emits results as text, not tool_result).
   * EventRenderer uses this to avoid showing a misleading in-progress spinner.
   */
  settled?: boolean;
};
 
/** Merges consecutive "text" events into single entries with concatenated content. */
export function groupConsecutiveTextEvents(events: SessionEvent[]): SessionEvent[] {
  const result: SessionEvent[] = [];
  for (const event of events) {
    const previous = result[result.length - 1];
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- previous is undefined on first iteration
    if (event.eventType === "text" && previous?.eventType === "text") {
      result[result.length - 1] = { ...previous, content: previous.content + event.content };
    } else {
      result.push(event);
    }
  }
  return result;
}
 
/**
 * Extracts the tool-use ID from a tool_use event's raw metadata.
 *
 * Different runtimes store the ID in different locations:
 * - Claude Code (Anthropic SDK): `raw.id` (e.g. "toolu_...")
 * - Copilot: `raw.data.toolCallId` (e.g. "call_...")
 * - Codex: `raw.item.id` (e.g. "item_1")
 */
function extractToolUseId(raw: Record<string, unknown>): string | undefined {
  if (typeof raw.id === "string") {
    return raw.id;
  }
  const data = raw.data as Record<string, unknown> | undefined;
  if (data && typeof data.toolCallId === "string") {
    return data.toolCallId;
  }
  const item = raw.item as Record<string, unknown> | undefined;
  Eif (item && typeof item.id === "string") {
    return item.id;
  }
  return undefined;
}
 
/**
 * Extracts the tool-use ID from a tool_result event's raw metadata.
 *
 * Different runtimes store the back-reference in different locations:
 * - Claude Code (Anthropic SDK): `raw.tool_use_id`
 * - Copilot: `raw.data.toolCallId`
 * - Codex: `raw.item.id`
 */
function extractToolResultId(raw: Record<string, unknown>): string | undefined {
  if (typeof raw.tool_use_id === "string") {
    return raw.tool_use_id;
  }
  const data = raw.data as Record<string, unknown> | undefined;
  if (data && typeof data.toolCallId === "string") {
    return data.toolCallId;
  }
  const item = raw.item as Record<string, unknown> | undefined;
  Eif (item && typeof item.id === "string") {
    return item.id;
  }
  return undefined;
}
 
/**
 * Resolve a tool event's correlation id: prefer the first-class `toolCallId`
 * (AHP HR3), falling back to the legacy per-runtime `raw` parsing for events
 * logged before HR3. `extractId` is the legacy reader for the event's role.
 */
function toolIdOf(
  e: SessionEvent,
  raw: Record<string, unknown> | undefined,
  extractId: (raw: Record<string, unknown>) => string | undefined,
): string | undefined {
  if (e.toolCallId) {
    return e.toolCallId;
  }
  return raw ? extractId(raw) : undefined;
}
 
/** Pairs tool_use events with their tool_result counterparts. */
export function pairToolEvents(events: SessionEvent[]): DisplayEvent[] {
  const parsedRaw = new Map<SessionEvent, Record<string, unknown>>();
  for (const e of events) {
    if (!e.raw) continue;
    try {
      parsedRaw.set(e, JSON.parse(e.raw) as Record<string, unknown>);
    } catch {
      /* skip unparseable events */
    }
  }
 
  // Build a map of tool_use IDs → context (first-class toolCallId, else legacy raw).
  const toolUseById = new Map<string, { tool: string; args: unknown }>();
  for (const e of events) {
    if (e.eventType !== "tool_use") continue;
    const id = toolIdOf(e, parsedRaw.get(e), extractToolUseId);
    if (!id) continue;
    try {
      const content = JSON.parse(e.content) as { tool: string; args: unknown };
      toolUseById.set(id, { tool: content.tool, args: content.args });
    } catch {
      /* skip unparseable events */
    }
  }
 
  // ID-based pairing — match tool_result events to their tool_use by id. Every
  // runtime now emits a stable `toolCallId`, so there is no positional fallback
  // (which mispaired under concurrent/interleaved tool calls — AHP HR3).
  const consumedIds = new Set<string>();
  const display: DisplayEvent[] = events.map((e) => {
    if (e.eventType !== "tool_result") return e;
    const resultId = toolIdOf(e, parsedRaw.get(e), extractToolResultId);
    if (!resultId) return e;
    const ctx = toolUseById.get(resultId);
    Iif (!ctx) return e;
    consumedIds.add(resultId);
 
    // Extract detailedResult from content when it's a JSON object with detailedContent
    // (Copilot emits tool results in this format with embedded diffs).
    // Guard with startsWith check to avoid throwing on plain text / large outputs.
    let detailedResult: string | undefined;
    const contentStr: string = e.content.trim();
    if (contentStr.startsWith("{")) {
      try {
        const parsed = JSON.parse(contentStr) as Record<string, unknown>;
        if (typeof parsed.detailedContent === "string") {
          detailedResult = parsed.detailedContent;
        }
      } catch {
        /* content looks like JSON but isn't — skip */
      }
    }
 
    return { ...e, toolUseCtx: { ...ctx, detailedResult } };
  });
 
  // Filter out consumed tool_use events (their info is now embedded in tool_result).
  const filtered = display.filter((e) => {
    if (e.eventType !== "tool_use") return true;
    const id = toolIdOf(e, parsedRaw.get(e), extractToolUseId);
    return !(id && consumedIds.has(id));
  });
 
  // Phase 3: Mark remaining unpaired tool_use events as "settled" if subsequent
  // events prove the tool completed. This handles runtimes like Claude Code that
  // emit tool results as text events rather than tool_result events — without this,
  // the ShellCard shows a misleading in-progress spinner forever.
  for (let i = 0; i < filtered.length; i++) {
    if (filtered[i].eventType !== "tool_use") continue;
    // Only settle if there is at least one subsequent non-tool_use event.
    // This avoids prematurely settling in multi-tool sequences where only
    // more tool_use events follow (the tools may still be running).
    let hasNonToolUseAfter = false;
    for (let j = i + 1; j < filtered.length; j++) {
      if (filtered[j].eventType !== "tool_use") {
        hasNonToolUseAfter = true;
        break;
      }
    }
    if (hasNonToolUseAfter) {
      filtered[i] = { ...filtered[i], settled: true };
    }
  }
 
  return filtered;
}