Add client-side agent progress parser with tool call summarization

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 23:03:29 -05:00
parent e61afc9dc4
commit d7246cf062
2 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,308 @@
import type { ParsedMessage } from "../lib/types";
// ── Types ──────────────────────────────────────────────────
export interface AgentToolCall {
kind: "tool_call";
toolName: string;
toolUseId: string;
input: Record<string, unknown>;
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<string, unknown>;
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<string, AgentToolCall>();
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<string, string> = {
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, unknown>
): 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;
}
}

View File

@@ -1,6 +1,7 @@
export type {
MessageCategory,
ParsedMessage,
ProgressSubtype,
SessionEntry,
SessionListResponse,
SessionDetailResponse,