Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionParser.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45/**6* Claude Code Session Parser7*8* Parses JSONL session files for subagent transcripts. The main session9* metadata and messages are now loaded via the `@anthropic-ai/claude-agent-sdk`10* session APIs (see `sdkSessionAdapter.ts`).11*12* **Layer 2** — `parseSessionFileContent`13* Builds a linked list from JSONL. Every UUID-bearing entry becomes a ChainNode14* in a single map. No classification into buckets — just chain metadata + raw JSON.15*16* **Layer 3** — `buildSubagentSession`17* Walks the linked list from leaf to root, validates visible entries, and18* produces StoredMessage[] for display.19*/2021import {22AssistantMessageEntry,23ChainNode,24CustomTitleEntry,25ISubagentSession,26StoredMessage,27SummaryEntry,28UserMessageEntry,29vAssistantMessageEntry,30vChainNodeFields,31vCustomTitleEntry,32vSummaryEntry,33vUserMessageEntry,34} from './claudeSessionSchema';3536// #region Types3738/**39* Detailed error for failed parsing.40*/41interface ParseError {42lineNumber: number;43message: string;44line: string;45parsedType?: string;46}4748/**49* Result of parsing a session file (Layer 2 output).50*/51export interface LinkedListParseResult {52/** All UUID-bearing entries indexed by UUID */53readonly nodes: ReadonlyMap<string, ChainNode>;54/** Summary entries indexed by leaf UUID */55readonly summaries: ReadonlyMap<string, SummaryEntry>;56/** Custom title entry from /rename command, if present */57readonly customTitle: CustomTitleEntry | undefined;58/** Errors encountered during parsing */59readonly errors: readonly ParseError[];60/** Statistics about the parse */61readonly stats: ParseStats;62}6364/**65* Statistics from parsing a session file.66*/67interface ParseStats {68readonly totalLines: number;69readonly chainNodes: number;70readonly summaries: number;71readonly customTitles: number;72readonly queueOperations: number;73readonly errors: number;74readonly skippedEmpty: number;75}7677// #endregion7879// #region Layer 2 — Linked List Parser8081/**82* Parse a session file's content into a linked list of chain nodes.83*84* This is Layer 2 of the parser architecture. Every JSONL line with a `uuid`85* becomes a ChainNode in a single map. No classification into separate buckets.86* The effective parent is `logicalParentUuid ?? parentUuid`, which handles87* compact boundaries transparently.88*89* @param content The raw UTF-8 content of a .jsonl session file90* @param fileIdentifier Optional identifier for error messages (e.g., file path)91* @returns LinkedListParseResult with nodes, summaries, and errors92*/93export function parseSessionFileContent(94content: string,95fileIdentifier?: string96): LinkedListParseResult {97const nodes = new Map<string, ChainNode>();98const summaries = new Map<string, SummaryEntry>();99const errors: ParseError[] = [];100let customTitle: CustomTitleEntry | undefined;101102const stats = {103totalLines: 0,104chainNodes: 0,105summaries: 0,106customTitles: 0,107queueOperations: 0,108errors: 0,109skippedEmpty: 0,110};111112const lines = content.split('\n');113114for (let i = 0; i < lines.length; i++) {115const line = lines[i].trim();116stats.totalLines++;117118if (line.length === 0) {119stats.skippedEmpty++;120continue;121}122123const lineNumber = i + 1;124125// Parse JSON126let parsed: unknown;127try {128parsed = JSON.parse(line);129} catch (e) {130stats.errors++;131const message = e instanceof Error ? e.message : String(e);132errors.push({133lineNumber,134message: fileIdentifier135? `[${fileIdentifier}:${lineNumber}] JSON parse error: ${message}`136: `JSON parse error: ${message}`,137line: line.length > 100 ? line.substring(0, 100) + '...' : line,138});139continue;140}141142if (typeof parsed !== 'object' || parsed === null) {143stats.errors++;144errors.push({145lineNumber,146message: fileIdentifier147? `[${fileIdentifier}:${lineNumber}] Expected object, got ${typeof parsed}`148: `Expected object, got ${typeof parsed}`,149line: line.length > 100 ? line.substring(0, 100) + '...' : line,150});151continue;152}153154const raw = parsed as Record<string, unknown>;155156// Try custom title entry (user-assigned session name via /rename)157const customTitleResult = vCustomTitleEntry.validate(parsed);158if (!customTitleResult.error) {159stats.customTitles++;160customTitle = customTitleResult.content;161continue;162}163164// Try summary entry first (has no uuid/parentUuid chain)165const summaryResult = vSummaryEntry.validate(parsed);166if (!summaryResult.error) {167stats.summaries++;168const summary = summaryResult.content.summary.toLowerCase();169if (!summary.startsWith('api error:') && !summary.startsWith('invalid api key')) {170summaries.set(summaryResult.content.leafUuid, summaryResult.content);171}172continue;173}174175// Try extracting chain node fields (uuid + parent info)176const chainResult = vChainNodeFields.validate(parsed);177if (!chainResult.error) {178stats.chainNodes++;179const { uuid, logicalParentUuid, parentUuid } = chainResult.content;180nodes.set(uuid, {181uuid,182parentUuid: logicalParentUuid ?? parentUuid ?? null,183raw,184lineNumber,185});186continue;187}188189// No uuid — likely a queue-operation or other non-chain entry190if ('type' in raw && raw.type === 'queue-operation') {191stats.queueOperations++;192} else {193// Unknown entry — not a hard error, just skip194stats.queueOperations++;195}196}197198return {199nodes,200summaries,201customTitle,202errors,203stats,204};205}206207// #endregion208209// #region Layer 3 — Session Building210211/**212* Check if a chain node represents a visible entry.213*214* The generalized rule: if the entry has displayable content (a `message`215* field for user/assistant entries or a string `content` field for system216* entries), it is visible — unless one of the hiding booleans is set.217*/218function isVisibleNode(raw: Record<string, unknown>): boolean {219// Must have displayable content220const hasMessage = 'message' in raw && (raw.type === 'user' || raw.type === 'assistant');221const hasSystemContent = typeof raw.content === 'string' && (raw.content as string).length > 0 && raw.type !== 'user' && raw.type !== 'assistant';222if (!hasMessage && !hasSystemContent) {223return false;224}225// Compact summaries are synthetic and should not be rendered226if (raw.isCompactSummary === true) {227return false;228}229// Meta entries and transcript-only entries are not rendered230if (raw.isVisibleInTranscriptOnly === true) {231return false;232}233if (raw.isMeta === true) {234return false;235}236return true;237}238239/**240* Validate a visible node's raw data and produce a StoredMessage.241* Returns null if validation fails.242*/243function validateAndReviveNode(node: ChainNode): StoredMessage | null {244const raw = node.raw;245246if (raw.type === 'user') {247const result = vUserMessageEntry.validate(raw);248if (result.error) {249return null;250}251return reviveUserMessage(result.content);252}253254if (raw.type === 'assistant') {255const result = vAssistantMessageEntry.validate(raw);256if (result.error) {257return null;258}259return reviveAssistantMessage(result.content);260}261262// System entries (e.g., compact_boundary) with string content263if (typeof raw.content === 'string') {264return reviveSystemMessage(node);265}266267return null;268}269270// #endregion271272// #region Message Revival273274/**275* Convert a validated user message entry into a StoredMessage.276*/277function reviveUserMessage(entry: UserMessageEntry): StoredMessage {278return {279uuid: entry.uuid,280sessionId: entry.sessionId,281timestamp: new Date(entry.timestamp),282parentUuid: entry.parentUuid ?? null,283type: 'user',284message: entry.message,285isSidechain: entry.isSidechain,286userType: entry.userType,287cwd: entry.cwd,288version: entry.version,289gitBranch: entry.gitBranch,290slug: entry.slug,291agentId: entry.agentId,292};293}294295/**296* Convert a validated assistant message entry into a StoredMessage.297*/298function reviveAssistantMessage(entry: AssistantMessageEntry): StoredMessage {299return {300uuid: entry.uuid,301sessionId: entry.sessionId,302timestamp: new Date(entry.timestamp),303parentUuid: entry.parentUuid ?? null,304type: 'assistant',305message: entry.message,306isSidechain: entry.isSidechain,307userType: entry.userType,308cwd: entry.cwd,309version: entry.version,310gitBranch: entry.gitBranch,311slug: entry.slug,312agentId: entry.agentId,313};314}315316/**317* Convert a system chain node into a StoredMessage.318* System entries (e.g., compact_boundary) carry a plain string `content` field.319*/320function reviveSystemMessage(node: ChainNode): StoredMessage | null {321const raw = node.raw;322const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : undefined;323const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : undefined;324const content = typeof raw.content === 'string' ? raw.content : undefined;325326if (!sessionId || !timestamp || !content) {327return null;328}329330return {331uuid: node.uuid,332sessionId,333timestamp: new Date(timestamp),334parentUuid: node.parentUuid,335type: 'system',336message: { role: 'system', content },337version: typeof raw.version === 'string' ? raw.version : undefined,338};339}340341// #endregion342343// #region Subagent Building344345/**346* Build an ISubagentSession from parsed file content.347* Subagent files have the same JSONL format as main session files.348*/349export function buildSubagentSession(350agentId: string,351parseResult: LinkedListParseResult352): ISubagentSession | null {353const { nodes } = parseResult;354355// Find leaf nodes356const referencedAsParent = new Set<string>();357for (const node of nodes.values()) {358if (node.parentUuid !== null) {359referencedAsParent.add(node.parentUuid);360}361}362363const leafUuids: string[] = [];364for (const uuid of nodes.keys()) {365if (!referencedAsParent.has(uuid)) {366leafUuids.push(uuid);367}368}369370if (leafUuids.length === 0) {371return null;372}373374// Build chain from the leaf with the most visible messages375let bestChain: StoredMessage[] = [];376377for (const leafUuid of leafUuids) {378const chain: StoredMessage[] = [];379const visited = new Set<string>();380let currentUuid: string | null = leafUuid;381382while (currentUuid !== null) {383if (visited.has(currentUuid)) {384break;385}386visited.add(currentUuid);387388const node = nodes.get(currentUuid);389if (node === undefined) {390break;391}392393if (isVisibleNode(node.raw)) {394const storedMessage = validateAndReviveNode(node);395if (storedMessage !== null) {396chain.unshift(storedMessage);397}398}399400currentUuid = node.parentUuid;401}402403if (chain.length > bestChain.length) {404bestChain = chain;405}406}407408if (bestChain.length === 0) {409return null;410}411412return {413agentId,414messages: bestChain,415timestamp: bestChain[bestChain.length - 1].timestamp,416};417}418419// #endregion420421422