Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionParser.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
/**
7
* Claude Code Session Parser
8
*
9
* Parses JSONL session files for subagent transcripts. The main session
10
* metadata and messages are now loaded via the `@anthropic-ai/claude-agent-sdk`
11
* session APIs (see `sdkSessionAdapter.ts`).
12
*
13
* **Layer 2** — `parseSessionFileContent`
14
* Builds a linked list from JSONL. Every UUID-bearing entry becomes a ChainNode
15
* in a single map. No classification into buckets — just chain metadata + raw JSON.
16
*
17
* **Layer 3** — `buildSubagentSession`
18
* Walks the linked list from leaf to root, validates visible entries, and
19
* produces StoredMessage[] for display.
20
*/
21
22
import {
23
AssistantMessageEntry,
24
ChainNode,
25
CustomTitleEntry,
26
ISubagentSession,
27
StoredMessage,
28
SummaryEntry,
29
UserMessageEntry,
30
vAssistantMessageEntry,
31
vChainNodeFields,
32
vCustomTitleEntry,
33
vSummaryEntry,
34
vUserMessageEntry,
35
} from './claudeSessionSchema';
36
37
// #region Types
38
39
/**
40
* Detailed error for failed parsing.
41
*/
42
interface ParseError {
43
lineNumber: number;
44
message: string;
45
line: string;
46
parsedType?: string;
47
}
48
49
/**
50
* Result of parsing a session file (Layer 2 output).
51
*/
52
export interface LinkedListParseResult {
53
/** All UUID-bearing entries indexed by UUID */
54
readonly nodes: ReadonlyMap<string, ChainNode>;
55
/** Summary entries indexed by leaf UUID */
56
readonly summaries: ReadonlyMap<string, SummaryEntry>;
57
/** Custom title entry from /rename command, if present */
58
readonly customTitle: CustomTitleEntry | undefined;
59
/** Errors encountered during parsing */
60
readonly errors: readonly ParseError[];
61
/** Statistics about the parse */
62
readonly stats: ParseStats;
63
}
64
65
/**
66
* Statistics from parsing a session file.
67
*/
68
interface ParseStats {
69
readonly totalLines: number;
70
readonly chainNodes: number;
71
readonly summaries: number;
72
readonly customTitles: number;
73
readonly queueOperations: number;
74
readonly errors: number;
75
readonly skippedEmpty: number;
76
}
77
78
// #endregion
79
80
// #region Layer 2 — Linked List Parser
81
82
/**
83
* Parse a session file's content into a linked list of chain nodes.
84
*
85
* This is Layer 2 of the parser architecture. Every JSONL line with a `uuid`
86
* becomes a ChainNode in a single map. No classification into separate buckets.
87
* The effective parent is `logicalParentUuid ?? parentUuid`, which handles
88
* compact boundaries transparently.
89
*
90
* @param content The raw UTF-8 content of a .jsonl session file
91
* @param fileIdentifier Optional identifier for error messages (e.g., file path)
92
* @returns LinkedListParseResult with nodes, summaries, and errors
93
*/
94
export function parseSessionFileContent(
95
content: string,
96
fileIdentifier?: string
97
): LinkedListParseResult {
98
const nodes = new Map<string, ChainNode>();
99
const summaries = new Map<string, SummaryEntry>();
100
const errors: ParseError[] = [];
101
let customTitle: CustomTitleEntry | undefined;
102
103
const stats = {
104
totalLines: 0,
105
chainNodes: 0,
106
summaries: 0,
107
customTitles: 0,
108
queueOperations: 0,
109
errors: 0,
110
skippedEmpty: 0,
111
};
112
113
const lines = content.split('\n');
114
115
for (let i = 0; i < lines.length; i++) {
116
const line = lines[i].trim();
117
stats.totalLines++;
118
119
if (line.length === 0) {
120
stats.skippedEmpty++;
121
continue;
122
}
123
124
const lineNumber = i + 1;
125
126
// Parse JSON
127
let parsed: unknown;
128
try {
129
parsed = JSON.parse(line);
130
} catch (e) {
131
stats.errors++;
132
const message = e instanceof Error ? e.message : String(e);
133
errors.push({
134
lineNumber,
135
message: fileIdentifier
136
? `[${fileIdentifier}:${lineNumber}] JSON parse error: ${message}`
137
: `JSON parse error: ${message}`,
138
line: line.length > 100 ? line.substring(0, 100) + '...' : line,
139
});
140
continue;
141
}
142
143
if (typeof parsed !== 'object' || parsed === null) {
144
stats.errors++;
145
errors.push({
146
lineNumber,
147
message: fileIdentifier
148
? `[${fileIdentifier}:${lineNumber}] Expected object, got ${typeof parsed}`
149
: `Expected object, got ${typeof parsed}`,
150
line: line.length > 100 ? line.substring(0, 100) + '...' : line,
151
});
152
continue;
153
}
154
155
const raw = parsed as Record<string, unknown>;
156
157
// Try custom title entry (user-assigned session name via /rename)
158
const customTitleResult = vCustomTitleEntry.validate(parsed);
159
if (!customTitleResult.error) {
160
stats.customTitles++;
161
customTitle = customTitleResult.content;
162
continue;
163
}
164
165
// Try summary entry first (has no uuid/parentUuid chain)
166
const summaryResult = vSummaryEntry.validate(parsed);
167
if (!summaryResult.error) {
168
stats.summaries++;
169
const summary = summaryResult.content.summary.toLowerCase();
170
if (!summary.startsWith('api error:') && !summary.startsWith('invalid api key')) {
171
summaries.set(summaryResult.content.leafUuid, summaryResult.content);
172
}
173
continue;
174
}
175
176
// Try extracting chain node fields (uuid + parent info)
177
const chainResult = vChainNodeFields.validate(parsed);
178
if (!chainResult.error) {
179
stats.chainNodes++;
180
const { uuid, logicalParentUuid, parentUuid } = chainResult.content;
181
nodes.set(uuid, {
182
uuid,
183
parentUuid: logicalParentUuid ?? parentUuid ?? null,
184
raw,
185
lineNumber,
186
});
187
continue;
188
}
189
190
// No uuid — likely a queue-operation or other non-chain entry
191
if ('type' in raw && raw.type === 'queue-operation') {
192
stats.queueOperations++;
193
} else {
194
// Unknown entry — not a hard error, just skip
195
stats.queueOperations++;
196
}
197
}
198
199
return {
200
nodes,
201
summaries,
202
customTitle,
203
errors,
204
stats,
205
};
206
}
207
208
// #endregion
209
210
// #region Layer 3 — Session Building
211
212
/**
213
* Check if a chain node represents a visible entry.
214
*
215
* The generalized rule: if the entry has displayable content (a `message`
216
* field for user/assistant entries or a string `content` field for system
217
* entries), it is visible — unless one of the hiding booleans is set.
218
*/
219
function isVisibleNode(raw: Record<string, unknown>): boolean {
220
// Must have displayable content
221
const hasMessage = 'message' in raw && (raw.type === 'user' || raw.type === 'assistant');
222
const hasSystemContent = typeof raw.content === 'string' && (raw.content as string).length > 0 && raw.type !== 'user' && raw.type !== 'assistant';
223
if (!hasMessage && !hasSystemContent) {
224
return false;
225
}
226
// Compact summaries are synthetic and should not be rendered
227
if (raw.isCompactSummary === true) {
228
return false;
229
}
230
// Meta entries and transcript-only entries are not rendered
231
if (raw.isVisibleInTranscriptOnly === true) {
232
return false;
233
}
234
if (raw.isMeta === true) {
235
return false;
236
}
237
return true;
238
}
239
240
/**
241
* Validate a visible node's raw data and produce a StoredMessage.
242
* Returns null if validation fails.
243
*/
244
function validateAndReviveNode(node: ChainNode): StoredMessage | null {
245
const raw = node.raw;
246
247
if (raw.type === 'user') {
248
const result = vUserMessageEntry.validate(raw);
249
if (result.error) {
250
return null;
251
}
252
return reviveUserMessage(result.content);
253
}
254
255
if (raw.type === 'assistant') {
256
const result = vAssistantMessageEntry.validate(raw);
257
if (result.error) {
258
return null;
259
}
260
return reviveAssistantMessage(result.content);
261
}
262
263
// System entries (e.g., compact_boundary) with string content
264
if (typeof raw.content === 'string') {
265
return reviveSystemMessage(node);
266
}
267
268
return null;
269
}
270
271
// #endregion
272
273
// #region Message Revival
274
275
/**
276
* Convert a validated user message entry into a StoredMessage.
277
*/
278
function reviveUserMessage(entry: UserMessageEntry): StoredMessage {
279
return {
280
uuid: entry.uuid,
281
sessionId: entry.sessionId,
282
timestamp: new Date(entry.timestamp),
283
parentUuid: entry.parentUuid ?? null,
284
type: 'user',
285
message: entry.message,
286
isSidechain: entry.isSidechain,
287
userType: entry.userType,
288
cwd: entry.cwd,
289
version: entry.version,
290
gitBranch: entry.gitBranch,
291
slug: entry.slug,
292
agentId: entry.agentId,
293
};
294
}
295
296
/**
297
* Convert a validated assistant message entry into a StoredMessage.
298
*/
299
function reviveAssistantMessage(entry: AssistantMessageEntry): StoredMessage {
300
return {
301
uuid: entry.uuid,
302
sessionId: entry.sessionId,
303
timestamp: new Date(entry.timestamp),
304
parentUuid: entry.parentUuid ?? null,
305
type: 'assistant',
306
message: entry.message,
307
isSidechain: entry.isSidechain,
308
userType: entry.userType,
309
cwd: entry.cwd,
310
version: entry.version,
311
gitBranch: entry.gitBranch,
312
slug: entry.slug,
313
agentId: entry.agentId,
314
};
315
}
316
317
/**
318
* Convert a system chain node into a StoredMessage.
319
* System entries (e.g., compact_boundary) carry a plain string `content` field.
320
*/
321
function reviveSystemMessage(node: ChainNode): StoredMessage | null {
322
const raw = node.raw;
323
const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : undefined;
324
const timestamp = typeof raw.timestamp === 'string' ? raw.timestamp : undefined;
325
const content = typeof raw.content === 'string' ? raw.content : undefined;
326
327
if (!sessionId || !timestamp || !content) {
328
return null;
329
}
330
331
return {
332
uuid: node.uuid,
333
sessionId,
334
timestamp: new Date(timestamp),
335
parentUuid: node.parentUuid,
336
type: 'system',
337
message: { role: 'system', content },
338
version: typeof raw.version === 'string' ? raw.version : undefined,
339
};
340
}
341
342
// #endregion
343
344
// #region Subagent Building
345
346
/**
347
* Build an ISubagentSession from parsed file content.
348
* Subagent files have the same JSONL format as main session files.
349
*/
350
export function buildSubagentSession(
351
agentId: string,
352
parseResult: LinkedListParseResult
353
): ISubagentSession | null {
354
const { nodes } = parseResult;
355
356
// Find leaf nodes
357
const referencedAsParent = new Set<string>();
358
for (const node of nodes.values()) {
359
if (node.parentUuid !== null) {
360
referencedAsParent.add(node.parentUuid);
361
}
362
}
363
364
const leafUuids: string[] = [];
365
for (const uuid of nodes.keys()) {
366
if (!referencedAsParent.has(uuid)) {
367
leafUuids.push(uuid);
368
}
369
}
370
371
if (leafUuids.length === 0) {
372
return null;
373
}
374
375
// Build chain from the leaf with the most visible messages
376
let bestChain: StoredMessage[] = [];
377
378
for (const leafUuid of leafUuids) {
379
const chain: StoredMessage[] = [];
380
const visited = new Set<string>();
381
let currentUuid: string | null = leafUuid;
382
383
while (currentUuid !== null) {
384
if (visited.has(currentUuid)) {
385
break;
386
}
387
visited.add(currentUuid);
388
389
const node = nodes.get(currentUuid);
390
if (node === undefined) {
391
break;
392
}
393
394
if (isVisibleNode(node.raw)) {
395
const storedMessage = validateAndReviveNode(node);
396
if (storedMessage !== null) {
397
chain.unshift(storedMessage);
398
}
399
}
400
401
currentUuid = node.parentUuid;
402
}
403
404
if (chain.length > bestChain.length) {
405
bestChain = chain;
406
}
407
}
408
409
if (bestChain.length === 0) {
410
return null;
411
}
412
413
return {
414
agentId,
415
messages: bestChain,
416
timestamp: bestChain[bestChain.length - 1].timestamp,
417
};
418
}
419
420
// #endregion
421
422