From d7246cf0626394d9a8aa1a8a6d27a5a12cb62bab Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 23:03:29 -0500 Subject: [PATCH] Add client-side agent progress parser with tool call summarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New agent-progress-parser.ts provides rich parsing of agent_progress JSON payloads from hook_progress events into a structured event stream: Event types: AgentToolCall, AgentToolResult, AgentTextResponse, AgentUserText, AgentRawContent — unified under AgentEvent discriminated union with ParsedAgentProgress metadata (prompt, agentId, timestamps, turnCount). Key capabilities: - Parse nested message.message.content blocks from agent progress JSON - Post-pass linking of tool_results to their preceding tool_calls via toolUseId map, enriching results with sourceTool and language hints - Language detection from file paths (30+ extensions mapped) - stripLineNumbers() to remove cat-n style prefixes for syntax detection - summarizeToolCall() for human-readable one-line summaries of common Claude tools (Read, Write, Edit, Grep, Glob, Bash, Task, WarpGrep) Also re-exports ProgressSubtype from client types barrel. Co-Authored-By: Claude Opus 4.5 --- src/client/lib/agent-progress-parser.ts | 308 ++++++++++++++++++++++++ src/client/lib/types.ts | 1 + 2 files changed, 309 insertions(+) create mode 100644 src/client/lib/agent-progress-parser.ts diff --git a/src/client/lib/agent-progress-parser.ts b/src/client/lib/agent-progress-parser.ts new file mode 100644 index 0000000..36f073f --- /dev/null +++ b/src/client/lib/agent-progress-parser.ts @@ -0,0 +1,308 @@ +import type { ParsedMessage } from "../lib/types"; + +// ── Types ────────────────────────────────────────────────── + +export interface AgentToolCall { + kind: "tool_call"; + toolName: string; + toolUseId: string; + input: Record; + timestamp?: string; +} + +export interface AgentToolResult { + kind: "tool_result"; + toolUseId: string; + content: string; + timestamp?: string; + /** Language hint derived from the preceding tool_call's file path */ + language?: string; + /** Tool name of the preceding tool_call (Read, Grep, Bash, etc.) */ + sourceTool?: string; +} + +export interface AgentTextResponse { + kind: "text_response"; + text: string; + lineCount: number; + timestamp?: string; +} + +export interface AgentUserText { + kind: "user_text"; + text: string; + timestamp?: string; +} + +export interface AgentRawContent { + kind: "raw_content"; + content: string; + timestamp?: string; +} + +export type AgentEvent = + | AgentToolCall + | AgentToolResult + | AgentTextResponse + | AgentUserText + | AgentRawContent; + +export interface ParsedAgentProgress { + events: AgentEvent[]; + prompt?: string; + agentId?: string; + firstTimestamp?: string; + lastTimestamp?: string; + turnCount: number; +} + +// ── Content block types from agent progress JSON ─────────── + +interface ContentBlock { + type: string; + id?: string; + text?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: string | ContentBlock[]; +} + +interface AgentProgressData { + message?: { + type?: string; + message?: { + role?: string; + content?: ContentBlock[]; + }; + timestamp?: string; + }; + type?: string; + prompt?: string; + agentId?: string; +} + +// ── Parsing ──────────────────────────────────────────────── + +export function parseAgentEvents( + rawEvents: ParsedMessage[] +): ParsedAgentProgress { + if (rawEvents.length === 0) { + return { events: [], turnCount: 0 }; + } + + const events: AgentEvent[] = []; + let prompt: string | undefined; + let agentId: string | undefined; + const timestamps: string[] = []; + + for (const raw of rawEvents) { + let data: AgentProgressData; + try { + data = JSON.parse(raw.content); + } catch { + events.push({ + kind: "raw_content", + content: raw.content, + timestamp: raw.timestamp, + }); + if (raw.timestamp) timestamps.push(raw.timestamp); + continue; + } + + // Extract metadata from first event that has it + if (!prompt && data.prompt) prompt = data.prompt; + if (!agentId && data.agentId) agentId = data.agentId; + + const msg = data.message; + const ts = msg?.timestamp || raw.timestamp; + if (ts) timestamps.push(ts); + + const contentBlocks = msg?.message?.content; + if (!Array.isArray(contentBlocks)) { + events.push({ + kind: "raw_content", + content: raw.content, + timestamp: ts, + }); + continue; + } + + const msgType = msg?.type; // "user" | "assistant" + + for (const block of contentBlocks) { + if (block.type === "tool_use") { + events.push({ + kind: "tool_call", + toolName: block.name || "unknown", + toolUseId: block.id || "", + input: block.input || {}, + timestamp: ts, + }); + } else if (block.type === "tool_result") { + const resultContent = + typeof block.content === "string" + ? block.content + : Array.isArray(block.content) + ? block.content + .map((b: ContentBlock) => b.text || "") + .join("") + : JSON.stringify(block.content); + events.push({ + kind: "tool_result", + toolUseId: block.tool_use_id || "", + content: resultContent, + timestamp: ts, + }); + } else if (block.type === "text" && block.text) { + if (msgType === "user") { + events.push({ + kind: "user_text", + text: block.text, + timestamp: ts, + }); + } else { + const lineCount = block.text.split("\n").length; + events.push({ + kind: "text_response", + text: block.text, + lineCount, + timestamp: ts, + }); + } + } + } + } + + // Post-pass: link tool_results to their preceding tool_calls + const callMap = new Map(); + for (const ev of events) { + if (ev.kind === "tool_call") { + callMap.set(ev.toolUseId, ev); + } else if (ev.kind === "tool_result" && ev.toolUseId) { + const call = callMap.get(ev.toolUseId); + if (call) { + ev.sourceTool = call.toolName; + const filePath = extractFilePath(call); + if (filePath) { + ev.language = languageFromPath(filePath); + } + } + } + } + + const turnCount = events.filter((e) => e.kind === "tool_call").length; + + return { + events, + prompt, + agentId, + firstTimestamp: timestamps[0], + lastTimestamp: timestamps[timestamps.length - 1], + turnCount, + }; +} + +// ── Language detection ────────────────────────────────────── + +function extractFilePath(call: AgentToolCall): string | undefined { + return (call.input.file_path as string) || (call.input.path as string) || undefined; +} + +const EXT_TO_LANG: Record = { + ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", + py: "python", rb: "ruby", go: "go", rs: "rust", + java: "java", kt: "kotlin", swift: "swift", + c: "c", cpp: "cpp", h: "c", hpp: "cpp", + css: "css", scss: "scss", less: "less", + html: "html", xml: "xml", svg: "xml", + json: "json", yaml: "yaml", yml: "yaml", toml: "toml", + md: "markdown", sh: "bash", bash: "bash", zsh: "bash", + sql: "sql", graphql: "graphql", + dockerfile: "dockerfile", +}; + +function languageFromPath(filePath: string): string | undefined { + const basename = filePath.split("/").pop() || ""; + if (basename.toLowerCase() === "dockerfile") return "dockerfile"; + const ext = basename.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + return EXT_TO_LANG[ext]; +} + +/** + * Strip `cat -n` style line number prefixes (e.g. " 1→" or " 42→") + * so syntax highlighters can detect the language. + */ +export function stripLineNumbers(text: string): string { + // Match lines starting with optional spaces, digits, then → (the arrow from cat -n) + const lines = text.split("\n"); + if (lines.length < 2) return text; + // Check if most lines have the pattern + const pattern = /^\s*\d+\u2192/; + const matchCount = lines.filter((l) => pattern.test(l) || l.trim() === "").length; + if (matchCount < lines.length * 0.5) return text; + return lines + .map((l) => { + const match = l.match(/^\s*\d+\u2192(.*)/); + return match ? match[1] : l; + }) + .join("\n"); +} + +// ── Tool call summarization ──────────────────────────────── + +function stripLeadingSlash(p: string): string { + return p.startsWith("/") ? p.slice(1) : p; +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max - 1) + "\u2026"; +} + +export function summarizeToolCall( + toolName: string, + input: Record +): string { + const filePath = input.file_path as string | undefined; + const path = input.path as string | undefined; + + switch (toolName) { + case "Read": + return filePath ? stripLeadingSlash(filePath) : "Read"; + case "Write": + return filePath ? stripLeadingSlash(filePath) : "Write"; + case "Edit": + return filePath ? stripLeadingSlash(filePath) : "Edit"; + case "mcp__morph-mcp__edit_file": + return path ? stripLeadingSlash(path) : "edit_file"; + case "Grep": { + const pattern = input.pattern as string | undefined; + const parts: string[] = []; + if (pattern) parts.push(`"${pattern}"`); + if (path) parts.push(`in ${stripLeadingSlash(path)}`); + return parts.length > 0 ? parts.join(" ") : "Grep"; + } + case "Glob": { + const pattern = input.pattern as string | undefined; + const parts: string[] = []; + if (pattern) parts.push(pattern); + if (path) parts.push(`in ${stripLeadingSlash(path)}`); + return parts.length > 0 ? parts.join(" ") : "Glob"; + } + case "Bash": { + const command = input.command as string | undefined; + return command ? truncate(command, 80) : "Bash"; + } + case "Task": { + const desc = input.description as string | undefined; + return desc ? truncate(desc, 60) : "Task"; + } + case "mcp__morph-mcp__warpgrep_codebase_search": { + const searchString = input.search_string as string | undefined; + return searchString ? truncate(searchString, 60) : "codebase_search"; + } + default: + return toolName; + } +} diff --git a/src/client/lib/types.ts b/src/client/lib/types.ts index 48a48a3..c2c2639 100644 --- a/src/client/lib/types.ts +++ b/src/client/lib/types.ts @@ -1,6 +1,7 @@ export type { MessageCategory, ParsedMessage, + ProgressSubtype, SessionEntry, SessionListResponse, SessionDetailResponse,