Files
session-viewer/src/server/services/session-parser.ts
teernisse b168e6ffd7 Add progress tracking fields to shared types and session parser
Introduce ProgressSubtype union ("hook" | "bash" | "mcp" | "agent") and
three new fields on ParsedMessage: toolUseId, parentToolUseId, and
progressSubtype. These enable linking hook_progress events to the
tool_call that spawned them and classifying progress by source.

Session parser changes:
- Extract `id` from tool_use content blocks into toolUseId
- Extract `tool_use_id` from tool_result blocks into toolUseId (was
  previously misassigned to toolName)
- Read `parentToolUseID` from raw progress lines
- Derive progressSubtype from the `data.type` field using a new
  deriveProgressSubtype() helper
- Add `toolProgress` map to SessionDetailResponse for grouped progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:03:00 -05:00

256 lines
7.3 KiB
TypeScript

import fs from "fs/promises";
import type { ParsedMessage, ProgressSubtype } from "../../shared/types.js";
/**
* Real Claude Code JSONL format (verified from actual session files):
*
* Every line has: { type, uuid?, timestamp?, ... }
*
* type="user" → { message: { role: "user", content: string | ContentBlock[] }, uuid, timestamp }
* type="assistant" → { message: { role: "assistant", content: ContentBlock[] }, uuid, timestamp }
* type="progress" → { data: { type, hookEvent?, status?, ... }, uuid, timestamp }
* type="summary" → { summary: string, leafUuid }
* type="file-history-snapshot" → { snapshot: { ... }, messageId }
* type="system" → { subtype: "turn_duration", durationMs, ... } (metadata, not display)
* type="queue-operation" → internal (not display)
*
* ContentBlock: { type: "text", text } | { type: "thinking", thinking } | { type: "tool_use", name, input } | { type: "tool_result", content, tool_use_id }
*/
interface ContentBlock {
type: string;
id?: string;
text?: string;
thinking?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
content?: string | ContentBlock[];
}
interface RawLine {
type?: string;
uuid?: string;
timestamp?: string;
parentToolUseID?: string;
message?: {
role?: string;
content?: string | ContentBlock[];
};
data?: Record<string, unknown>;
summary?: string;
snapshot?: Record<string, unknown>;
subtype?: string;
}
export async function parseSession(
filePath: string
): Promise<ParsedMessage[]> {
let content: string;
try {
content = await fs.readFile(filePath, "utf-8");
} catch {
return [];
}
return parseSessionContent(content);
}
export function parseSessionContent(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
const lines = content.split("\n").filter((l) => l.trim());
for (let i = 0; i < lines.length; i++) {
let parsed: RawLine;
try {
parsed = JSON.parse(lines[i]);
} catch {
continue; // Skip malformed lines
}
const extracted = extractMessages(parsed, i);
messages.push(...extracted);
}
return messages;
}
function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
const messages: ParsedMessage[] = [];
const type = raw.type;
const uuid = raw.uuid || `generated-${rawIndex}`;
const timestamp = raw.timestamp;
// Progress/hook messages - content is in `data`, not `content`
if (type === "progress") {
const data = raw.data;
const progressText = data
? formatProgressData(data)
: "Progress event";
const dataType = typeof data?.type === "string" ? data.type : "";
const progressSubtype = deriveProgressSubtype(dataType);
messages.push({
uuid,
category: "hook_progress",
content: progressText,
timestamp,
rawIndex,
parentToolUseId: raw.parentToolUseID,
progressSubtype,
});
return messages;
}
// File history snapshot
if (type === "file-history-snapshot") {
messages.push({
uuid,
category: "file_snapshot",
content: JSON.stringify(raw.snapshot || raw, null, 2),
timestamp,
rawIndex,
});
return messages;
}
// Summary message - text is in `summary` field, not `content`
if (type === "summary") {
messages.push({
uuid,
category: "summary",
content: raw.summary || "",
timestamp,
rawIndex,
});
return messages;
}
// System metadata (turn_duration etc.) - skip, not user-facing
if (type === "system" || type === "queue-operation") {
return messages;
}
// User and assistant messages - content is in `message.content`
const role = raw.message?.role;
const content = raw.message?.content;
if ((type === "user" || role === "user") && content !== undefined) {
if (typeof content === "string") {
const category = detectSystemReminder(content)
? "system_message"
: "user_message";
messages.push({
uuid,
category,
content,
timestamp,
rawIndex,
});
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "tool_result") {
const resultText =
typeof block.content === "string"
? block.content
: Array.isArray(block.content)
? block.content
.map((b: ContentBlock) => b.text || "")
.join("\n")
: JSON.stringify(block.content);
messages.push({
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
category: "tool_result",
content: resultText,
toolUseId: block.tool_use_id,
timestamp,
rawIndex,
});
} else if (block.type === "text") {
const text = block.text || "";
const category = detectSystemReminder(text)
? "system_message"
: "user_message";
messages.push({
uuid: `${uuid}-text-${rawIndex}`,
category,
content: text,
timestamp,
rawIndex,
});
}
}
}
return messages;
}
if ((type === "assistant" || role === "assistant") && content !== undefined) {
if (typeof content === "string") {
messages.push({
uuid,
category: "assistant_text",
content,
timestamp,
rawIndex,
});
} else if (Array.isArray(content)) {
for (let j = 0; j < content.length; j++) {
const block = content[j];
const blockUuid = `${uuid}-${j}`;
if (block.type === "thinking") {
messages.push({
uuid: blockUuid,
category: "thinking",
content: block.thinking || block.text || "",
timestamp,
rawIndex,
});
} else if (block.type === "text") {
messages.push({
uuid: blockUuid,
category: "assistant_text",
content: block.text || "",
timestamp,
rawIndex,
});
} else if (block.type === "tool_use") {
messages.push({
uuid: blockUuid,
category: "tool_call",
content: `Tool: ${block.name}`,
toolName: block.name,
toolInput: JSON.stringify(block.input, null, 2),
timestamp,
rawIndex,
toolUseId: block.id,
});
}
}
}
return messages;
}
return messages;
}
function formatProgressData(data: Record<string, unknown>): string {
const parts: string[] = [];
if (data.hookEvent) parts.push(`Hook: ${data.hookEvent}`);
if (data.hookName) parts.push(`${data.hookName}`);
if (data.toolName) parts.push(`Tool: ${data.toolName}`);
if (data.status) parts.push(`Status: ${data.status}`);
if (data.serverName) parts.push(`Server: ${data.serverName}`);
if (parts.length > 0) return parts.join(" | ");
return JSON.stringify(data);
}
function detectSystemReminder(text: string): boolean {
return text.includes("<system-reminder>") || text.includes("</system-reminder>");
}
function deriveProgressSubtype(dataType: string): ProgressSubtype {
if (dataType === "bash_progress") return "bash";
if (dataType === "mcp_progress") return "mcp";
if (dataType === "agent_progress") return "agent";
return "hook";
}