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:
308
src/client/lib/agent-progress-parser.ts
Normal file
308
src/client/lib/agent-progress-parser.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type {
|
export type {
|
||||||
MessageCategory,
|
MessageCategory,
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
|
ProgressSubtype,
|
||||||
SessionEntry,
|
SessionEntry,
|
||||||
SessionListResponse,
|
SessionListResponse,
|
||||||
SessionDetailResponse,
|
SessionDetailResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user