- Extend ParsedMessage.meta with toolInput to expose raw tool arguments - Add toolUseRegistry to correlate tool_result blocks back to originating tool_use - Set toolInput on tool_call messages and populate meta.toolName/toolInput on tool_result - Fix tool_result with is_error:true now correctly produces type "error" - Add catch-all for unknown top-level event types (emits system message) - Add catch-all for unknown assistant content block types (emits system message) - Add unit tests covering all 8 scenarios including regression cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
export interface ParsedMessage {
|
|
type:
|
|
| "text"
|
|
| "system"
|
|
| "tool_call"
|
|
| "tool_result"
|
|
| "session_end"
|
|
| "error";
|
|
content: string;
|
|
timestamp?: Date;
|
|
meta?: {
|
|
toolName?: string;
|
|
toolInput?: unknown;
|
|
isError?: boolean;
|
|
cost?: number;
|
|
duration?: number;
|
|
};
|
|
}
|
|
|
|
export function formatToolCall(toolUse: any): string {
|
|
const { name, input } = toolUse;
|
|
|
|
if (name === "Bash") {
|
|
return `$ ${input.command}${input.description ? "\n# " + input.description : ""}`;
|
|
}
|
|
|
|
if (name === "Read") {
|
|
return `Read: ${input.file_path}${input.offset ? ` (lines ${input.offset}-${input.offset + (input.limit || 10)})` : ""}`;
|
|
}
|
|
|
|
if (name === "Edit") {
|
|
return `Edit: ${input.file_path}\n${input.old_string.substring(0, 100)}${input.old_string.length > 100 ? "..." : ""}\n-> ${input.new_string.substring(0, 100)}${input.new_string.length > 100 ? "..." : ""}`;
|
|
}
|
|
|
|
if (name === "Write") {
|
|
return `Write: ${input.file_path} (${input.content.length} chars)`;
|
|
}
|
|
|
|
if (name === "Task") {
|
|
return `${input.subagent_type}: ${input.description}\n${input.prompt?.substring(0, 200)}${input.prompt && input.prompt.length > 200 ? "..." : ""}`;
|
|
}
|
|
|
|
return `${name}: ${JSON.stringify(input, null, 2)}`;
|
|
}
|
|
|
|
export function getMessageStyling(type: ParsedMessage["type"]): string {
|
|
switch (type) {
|
|
case "system":
|
|
return "mb-1";
|
|
case "text":
|
|
return "mb-1";
|
|
case "tool_call":
|
|
return "mb-2";
|
|
case "tool_result":
|
|
return "mb-2";
|
|
case "error":
|
|
return "mb-2";
|
|
case "session_end":
|
|
return "mb-2";
|
|
default:
|
|
return "mb-1";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A chunk of raw JSONL content with an optional timestamp from the DB.
|
|
*/
|
|
export interface TimestampedChunk {
|
|
content: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
/**
|
|
* Parse agent output. Accepts either a flat string (legacy) or timestamped chunks.
|
|
* When chunks have timestamps, each parsed message inherits the chunk's timestamp.
|
|
*/
|
|
export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] {
|
|
const chunks: { content: string; timestamp?: Date }[] =
|
|
typeof raw === "string"
|
|
? [{ content: raw }]
|
|
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
|
|
|
|
const parsedMessages: ParsedMessage[] = [];
|
|
const toolUseRegistry = new Map<string, { name: string; input: unknown }>();
|
|
|
|
for (const chunk of chunks) {
|
|
const lines = chunk.content.split("\n").filter(Boolean);
|
|
for (const line of lines) {
|
|
try {
|
|
const event = JSON.parse(line);
|
|
|
|
// System initialization
|
|
if (event.type === "system" && event.session_id) {
|
|
parsedMessages.push({
|
|
type: "system",
|
|
content: `Session started: ${event.session_id}`,
|
|
timestamp: chunk.timestamp,
|
|
});
|
|
}
|
|
|
|
// Assistant messages with text and tool calls
|
|
else if (
|
|
event.type === "assistant" &&
|
|
Array.isArray(event.message?.content)
|
|
) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === "text" && block.text) {
|
|
parsedMessages.push({
|
|
type: "text",
|
|
content: block.text,
|
|
timestamp: chunk.timestamp,
|
|
});
|
|
} else if (block.type === "tool_use") {
|
|
parsedMessages.push({
|
|
type: "tool_call",
|
|
content: formatToolCall(block),
|
|
timestamp: chunk.timestamp,
|
|
meta: { toolName: block.name, toolInput: block.input },
|
|
});
|
|
toolUseRegistry.set(block.id, { name: block.name, input: block.input });
|
|
} else {
|
|
parsedMessages.push({
|
|
type: "system",
|
|
content: `[unsupported content block: ${block.type}]`,
|
|
timestamp: chunk.timestamp,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// User messages with tool results
|
|
else if (
|
|
event.type === "user" &&
|
|
Array.isArray(event.message?.content)
|
|
) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === "tool_result") {
|
|
const rawContent = block.content;
|
|
const output =
|
|
typeof rawContent === "string"
|
|
? rawContent
|
|
: Array.isArray(rawContent)
|
|
? rawContent
|
|
.map((c: any) => c.text ?? JSON.stringify(c))
|
|
.join("\n")
|
|
: (event.tool_use_result?.stdout || "");
|
|
const stderr = event.tool_use_result?.stderr;
|
|
|
|
if (stderr) {
|
|
parsedMessages.push({
|
|
type: "error",
|
|
content: stderr,
|
|
timestamp: chunk.timestamp,
|
|
meta: { isError: true },
|
|
});
|
|
} else if (output) {
|
|
const displayOutput =
|
|
output.length > 1000
|
|
? output.substring(0, 1000) + "\n... (truncated)"
|
|
: output;
|
|
const isError = block.is_error === true;
|
|
const originatingCall = block.tool_use_id
|
|
? toolUseRegistry.get(block.tool_use_id)
|
|
: undefined;
|
|
parsedMessages.push({
|
|
type: isError ? "error" : "tool_result",
|
|
content: displayOutput,
|
|
timestamp: chunk.timestamp,
|
|
meta: {
|
|
...(isError ? { isError: true } : {}),
|
|
...(originatingCall
|
|
? { toolName: originatingCall.name, toolInput: originatingCall.input }
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy streaming format
|
|
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
|
parsedMessages.push({
|
|
type: "text",
|
|
content: event.event.delta.text,
|
|
timestamp: chunk.timestamp,
|
|
});
|
|
}
|
|
|
|
// Session completion
|
|
else if (event.type === "result") {
|
|
parsedMessages.push({
|
|
type: "session_end",
|
|
content: event.is_error ? "Session failed" : "Session completed",
|
|
timestamp: chunk.timestamp,
|
|
meta: {
|
|
isError: event.is_error,
|
|
cost: event.total_cost_usd,
|
|
duration: event.duration_ms,
|
|
},
|
|
});
|
|
} else {
|
|
parsedMessages.push({
|
|
type: "system",
|
|
content: `[unknown event: ${event.type ?? "(no type)"}]`,
|
|
timestamp: chunk.timestamp,
|
|
});
|
|
}
|
|
} catch {
|
|
// Not JSON, display as-is
|
|
parsedMessages.push({
|
|
type: "error",
|
|
content: line,
|
|
timestamp: chunk.timestamp,
|
|
meta: { isError: true },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return parsedMessages;
|
|
}
|