Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/chatHistoryBuilder.ts
13399 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
import * as vscode from 'vscode';
7
import { coalesce } from '../../../util/vs/base/common/arrays';
8
import { URI } from '../../../util/vs/base/common/uri';
9
import { ChatReferenceBinaryData, ChatRequestTurn2 } from '../../../vscodeTypes';
10
import { tryParseClaudeModelId } from '../claude/node/claudeModelId';
11
import { completeToolInvocation, createFormattedToolInvocation } from '../claude/common/toolInvocationFormatter';
12
import { AssistantMessageContent, ContentBlock, IClaudeCodeSession, ImageBlock, ISubagentSession, StoredMessage, SYNTHETIC_MODEL_ID, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock } from '../claude/node/sessionParser/claudeSessionSchema';
13
14
// #region Types
15
16
interface ToolContext {
17
unprocessedToolCalls: Map<string, ContentBlock>;
18
pendingToolInvocations: Map<string, vscode.ChatToolInvocationPart>;
19
}
20
21
// #endregion
22
23
// #region Type Guards
24
25
function isTextBlock(block: ContentBlock): block is TextBlock {
26
return block.type === 'text';
27
}
28
29
function isThinkingBlock(block: ContentBlock): block is ThinkingBlock {
30
return block.type === 'thinking';
31
}
32
33
function isToolUseBlock(block: ContentBlock): block is ToolUseBlock {
34
return block.type === 'tool_use';
35
}
36
37
function isToolResultBlock(block: ContentBlock): block is ToolResultBlock {
38
return block.type === 'tool_result';
39
}
40
41
function isImageBlock(block: ContentBlock): block is ImageBlock {
42
return block.type === 'image';
43
}
44
45
// #endregion
46
47
// #region Command Message Helpers
48
49
/**
50
* Regex patterns for Claude Code slash command XML tags in user message content.
51
* These are emitted by the Claude Code CLI when the user runs a local command
52
* (e.g., /compact, /init). The messages contain structured XML tags:
53
* - <command-name>/compact</command-name>
54
* - <command-message>compact</command-message>
55
* - <command-args>...</command-args>
56
* - <local-command-stdout>...</local-command-stdout>
57
*/
58
const COMMAND_NAME_PATTERN = /<command-name>([\s\S]*?)<\/command-name>/;
59
const COMMAND_STDOUT_PATTERN = /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/;
60
61
/**
62
* Scans user message contents for slash command patterns and extracts
63
* the command name and optional stdout output.
64
*
65
* Returns undefined if no command patterns are found.
66
*/
67
function extractCommandInfo(contents: readonly (string | ContentBlock[])[]): { commandName: string; stdout?: string } | undefined {
68
let commandName: string | undefined;
69
let stdout: string | undefined;
70
71
for (const content of contents) {
72
if (typeof content === 'string') {
73
const nameMatch = COMMAND_NAME_PATTERN.exec(content);
74
if (nameMatch) {
75
commandName ??= nameMatch[1].trim();
76
}
77
const stdoutMatch = COMMAND_STDOUT_PATTERN.exec(content);
78
if (stdoutMatch) {
79
stdout ??= stdoutMatch[1].trim();
80
}
81
} else {
82
for (const block of content) {
83
if (isTextBlock(block)) {
84
const nameMatch = COMMAND_NAME_PATTERN.exec(block.text);
85
if (nameMatch) {
86
commandName ??= nameMatch[1].trim();
87
}
88
const stdoutMatch = COMMAND_STDOUT_PATTERN.exec(block.text);
89
if (stdoutMatch) {
90
stdout ??= stdoutMatch[1].trim();
91
}
92
}
93
}
94
}
95
}
96
97
if (commandName !== undefined) {
98
return { commandName, stdout };
99
}
100
return undefined;
101
}
102
103
// #endregion
104
105
// #region Text Content Helpers
106
107
/**
108
* Checks if a text block contains a system-reminder tag.
109
* System-reminders are stored in separate content blocks and should not be rendered.
110
*/
111
function isSystemReminderBlock(text: string): boolean {
112
return text.includes('<system-reminder>');
113
}
114
115
/**
116
* Strips <system-reminder> tags and their content from a string.
117
* Used for backwards compatibility with legacy sessions where system-reminders
118
* were concatenated with user text in a single string.
119
*
120
* TODO: Remove this function after a few releases (added in 0.38.x) once legacy
121
* sessions with concatenated system-reminders are no longer common.
122
*/
123
function stripSystemReminders(text: string): string {
124
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, '');
125
}
126
127
/**
128
* Extracts visible text content from a user message, filtering out system reminders.
129
*/
130
function extractTextContent(content: string | ContentBlock[]): string {
131
if (typeof content === 'string') {
132
// TODO: Remove this branch when stripSystemReminders is removed (legacy compat)
133
return stripSystemReminders(content);
134
}
135
136
// For array content (new format), filter out entire blocks that are system-reminders
137
return content
138
.filter(isTextBlock)
139
.filter(block => !isSystemReminderBlock(block.text))
140
.map(block => block.text)
141
.join('');
142
}
143
144
// #endregion
145
146
// #region Tool Result Processing
147
148
/**
149
* Processes tool result blocks from a user message, matching them to pending
150
* tool invocations and marking them as complete.
151
*/
152
function processToolResults(content: string | ContentBlock[], toolContext: ToolContext): void {
153
if (typeof content === 'string') {
154
return;
155
}
156
157
for (const block of content) {
158
if (isToolResultBlock(block)) {
159
const toolUse = toolContext.unprocessedToolCalls.get(block.tool_use_id);
160
if (toolUse && isToolUseBlock(toolUse)) {
161
toolContext.unprocessedToolCalls.delete(block.tool_use_id);
162
const pendingInvocation = toolContext.pendingToolInvocations.get(block.tool_use_id);
163
if (pendingInvocation) {
164
pendingInvocation.isComplete = true;
165
pendingInvocation.isConfirmed = true;
166
pendingInvocation.isError = block.is_error;
167
// Populate tool output for display in chat UI
168
completeToolInvocation(toolUse, block, pendingInvocation);
169
toolContext.pendingToolInvocations.delete(block.tool_use_id);
170
}
171
}
172
}
173
}
174
}
175
176
// #endregion
177
178
// #region Image Reference Extraction
179
180
/**
181
* Extracts image blocks from user message contents and converts them to
182
* ChatPromptReference objects.
183
*
184
* - Base64 images become ChatReferenceBinaryData values (binary data for display).
185
* - URL images become URI values (the API stored a URL rather than inline data).
186
*/
187
function extractImageReferences(contents: readonly (string | ContentBlock[])[]): vscode.ChatPromptReference[] {
188
const references: vscode.ChatPromptReference[] = [];
189
let imageIndex = 0;
190
for (const content of contents) {
191
if (typeof content === 'string') {
192
continue;
193
}
194
for (const block of content) {
195
if (!isImageBlock(block)) {
196
continue;
197
}
198
const id = `image-${imageIndex + 1}`;
199
if (block.source.type === 'base64') {
200
const source = block.source;
201
// NOTE: The API does not give us any metadata about the image beyond the media type, so
202
// we use a generic name and the media type as the MIME type for the binary reference.
203
references.push({
204
id,
205
name: id,
206
value: new ChatReferenceBinaryData(
207
source.media_type,
208
() => Promise.resolve(Buffer.from(source.data, 'base64'))
209
),
210
});
211
imageIndex++;
212
} else if (block.source.type === 'url') {
213
references.push({
214
id,
215
name: id,
216
value: URI.parse(block.source.url),
217
});
218
imageIndex++;
219
}
220
}
221
}
222
return references;
223
}
224
225
// #endregion
226
227
// #region Turn Extraction
228
229
/**
230
* Extracts a request turn from user message contents, ignoring tool results.
231
* Returns undefined if the messages contain only tool results or system reminders.
232
*/
233
function extractUserRequest(contents: readonly (string | ContentBlock[])[], messageId: string, modelId: string | undefined): vscode.ChatRequestTurn2 | undefined {
234
const textParts: string[] = [];
235
for (const content of contents) {
236
const text = extractTextContent(content);
237
if (text.trim()) {
238
textParts.push(text);
239
}
240
}
241
242
const combinedText = textParts.join('\n\n');
243
const imageReferences = extractImageReferences(contents);
244
245
// If no visible text and no images, don't create a request turn
246
if (!combinedText.trim() && imageReferences.length === 0) {
247
return;
248
}
249
250
// If the message indicates it was interrupted, skip it
251
if (combinedText === '[Request interrupted by user]') {
252
return;
253
}
254
255
return new ChatRequestTurn2(combinedText, undefined, imageReferences, '', [], undefined, messageId, modelId, undefined);
256
}
257
258
/**
259
* Extracts response parts from consecutive assistant messages.
260
*/
261
function extractAssistantParts(messages: readonly AssistantMessageContent[], toolContext: ToolContext): (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] {
262
const allParts: (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] = [];
263
264
for (const message of messages) {
265
const parts = coalesce(message.content.map(block => {
266
if (isTextBlock(block)) {
267
return new vscode.ChatResponseMarkdownPart(new vscode.MarkdownString(block.text));
268
} else if (isThinkingBlock(block)) {
269
return new vscode.ChatResponseThinkingProgressPart(block.thinking);
270
} else if (isToolUseBlock(block)) {
271
toolContext.unprocessedToolCalls.set(block.id, block);
272
const toolInvocation = createFormattedToolInvocation(block);
273
if (toolInvocation) {
274
toolContext.pendingToolInvocations.set(block.id, toolInvocation);
275
}
276
return toolInvocation;
277
}
278
}));
279
allParts.push(...parts);
280
}
281
282
return allParts;
283
}
284
285
// #endregion
286
287
// #region Subagent Tool Extraction
288
289
/**
290
* Builds a map from parentToolUseId to ISubagentSession for quick lookup.
291
*/
292
function buildSubagentMap(subagents: readonly ISubagentSession[]): Map<string, ISubagentSession> {
293
const map = new Map<string, ISubagentSession>();
294
for (const subagent of subagents) {
295
if (subagent.parentToolUseId) {
296
map.set(subagent.parentToolUseId, subagent);
297
}
298
}
299
return map;
300
}
301
302
/**
303
* Extracts tool invocation parts from a subagent session's messages.
304
* These are the tool calls made by the subagent during its execution.
305
* Each tool invocation has subAgentInvocationId set to associate it with the parent Task.
306
*/
307
function extractSubagentToolParts(
308
subagent: ISubagentSession,
309
taskToolUseId: string
310
): vscode.ChatToolInvocationPart[] {
311
const toolContext: ToolContext = {
312
unprocessedToolCalls: new Map(),
313
pendingToolInvocations: new Map()
314
};
315
const parts: vscode.ChatToolInvocationPart[] = [];
316
317
for (const message of subagent.messages) {
318
if (message.type === 'assistant') {
319
const assistantContent = message.message as AssistantMessageContent;
320
for (const block of assistantContent.content) {
321
if (isToolUseBlock(block)) {
322
toolContext.unprocessedToolCalls.set(block.id, block);
323
const toolInvocation = createFormattedToolInvocation(block, true);
324
if (toolInvocation) {
325
toolInvocation.subAgentInvocationId = taskToolUseId;
326
toolContext.pendingToolInvocations.set(block.id, toolInvocation);
327
parts.push(toolInvocation);
328
}
329
}
330
}
331
} else if (message.type === 'user') {
332
const content = message.message.content;
333
if (typeof content !== 'string') {
334
processToolResults(content, toolContext);
335
}
336
}
337
}
338
339
return parts;
340
}
341
342
// #endregion
343
344
// #region Model ID Resolution
345
346
/**
347
* Looks ahead from a given index in the message array to find the model ID
348
* from the first non-synthetic assistant message. Converts SDK model IDs
349
* to endpoint format using {@link tryParseClaudeModelId}.
350
*
351
* @param messages The session's stored messages
352
* @param startIndex The index to start looking from (typically after user messages)
353
* @returns The endpoint model ID, or undefined if not found
354
*/
355
function findModelIdForRequest(
356
messages: readonly StoredMessage[],
357
startIndex: number,
358
): string | undefined {
359
for (let j = startIndex; j < messages.length; j++) {
360
const msg = messages[j];
361
if (msg.type === 'assistant' && msg.message.role === 'assistant') {
362
const assistantMsg = msg.message as AssistantMessageContent;
363
if (assistantMsg.model && assistantMsg.model !== SYNTHETIC_MODEL_ID) {
364
return tryParseClaudeModelId(assistantMsg.model)?.toEndpointModelId() ?? assistantMsg.model;
365
}
366
}
367
}
368
return undefined;
369
}
370
371
// #endregion
372
373
/**
374
* Converts a Claude Code session into VS Code chat history turns.
375
*
376
* In the Anthropic API, tool results are sent as user messages, so a single
377
* agentic turn (assistant calls tools, gets results, calls more tools, etc.)
378
* appears as alternating assistant/user messages in the JSONL. VS Code's chat
379
* API expects all of that to be a single ChatResponseTurn2, so we accumulate
380
* response parts across tool-result boundaries and only finalize a response
381
* when we encounter a user message with actual text (a new user request).
382
*
383
* @param session The Claude Code session to convert
384
* @param getModelDetails Optional lookup that returns the display string for a Claude
385
* model id (as it appears on stored assistant messages).
386
*/
387
export function buildChatHistory(session: IClaudeCodeSession, getModelDetails?: (modelId: string) => string | undefined): (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] {
388
const result: (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] = [];
389
const toolContext: ToolContext = {
390
unprocessedToolCalls: new Map(),
391
pendingToolInvocations: new Map()
392
};
393
let i = 0;
394
const messages = session.messages;
395
let pendingResponseParts: (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] = [];
396
// Tracks the most recent assistant model id observed in the current pending response
397
// group so we can populate `ChatResponseTurn2.result.details` when finalizing it.
398
let pendingResponseModelId: string | undefined;
399
const makeResponseResult = (modelId: string | undefined): vscode.ChatResult => {
400
if (!modelId || !getModelDetails) {
401
return {};
402
}
403
const details = getModelDetails(modelId);
404
return details ? { details } : {};
405
};
406
407
// Build a map from parentToolUseId to subagent for quick lookup
408
const subagentMap = buildSubagentMap(session.subagents);
409
410
while (i < messages.length) {
411
const currentType = messages[i].type;
412
const currentMessageId = messages[i].uuid;
413
if (currentType === 'user') {
414
// Collect all consecutive user messages (preserving the full StoredMessage for metadata)
415
const userMessages: StoredMessage[] = [];
416
while (i < messages.length && messages[i].type === 'user' && messages[i].message.role === 'user') {
417
userMessages.push(messages[i]);
418
i++;
419
}
420
421
const userContents = userMessages.map(m => m.message.content as string | ContentBlock[]);
422
423
// Always process tool results to update pending tool invocations
424
for (const content of userContents) {
425
processToolResults(content, toolContext);
426
}
427
428
// After processing tool results, inject subagent tool calls for subagents correlated via parentToolUseId.
429
// Each subagent's parentToolUseId links it to the Agent or legacy Task tool_use that spawned it.
430
// We match tool_result blocks in user messages to those subagents via tool_use_id.
431
for (const content of userContents) {
432
if (typeof content === 'string') {
433
continue;
434
}
435
for (const block of content) {
436
if (isToolResultBlock(block)) {
437
const subagent = subagentMap.get(block.tool_use_id);
438
if (subagent) {
439
const subagentParts = extractSubagentToolParts(subagent, block.tool_use_id);
440
pendingResponseParts.push(...subagentParts);
441
}
442
}
443
}
444
}
445
446
// Check for slash command patterns (e.g., /compact, /init)
447
const commandInfo = extractCommandInfo(userContents);
448
const modelId = findModelIdForRequest(messages, i);
449
if (commandInfo) {
450
// Finalize any pending response first
451
if (pendingResponseParts.length > 0) {
452
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));
453
pendingResponseParts = [];
454
pendingResponseModelId = undefined;
455
}
456
// Emit the command as a request turn
457
result.push(new ChatRequestTurn2(commandInfo.commandName, undefined, [], '', [], undefined, currentMessageId, modelId, undefined));
458
// Emit stdout as a response turn if present
459
if (commandInfo.stdout) {
460
result.push(new vscode.ChatResponseTurn2(
461
[new vscode.ChatResponseMarkdownPart(new vscode.MarkdownString(commandInfo.stdout))],
462
{},
463
''
464
));
465
}
466
} else {
467
// Check if there's actual user text (not just tool results)
468
const requestTurn = extractUserRequest(userContents, currentMessageId, modelId);
469
if (requestTurn) {
470
// Real user message — finalize any pending response first
471
if (pendingResponseParts.length > 0) {
472
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));
473
pendingResponseParts = [];
474
pendingResponseModelId = undefined;
475
}
476
result.push(requestTurn);
477
}
478
// Otherwise this was a tool-result-only message — don't break the response grouping
479
}
480
} else if (currentType === 'assistant') {
481
// Collect all consecutive assistant messages, skipping synthetic ones
482
// (e.g., "No response requested." from abort)
483
const assistantMessages: AssistantMessageContent[] = [];
484
while (i < messages.length && messages[i].type === 'assistant' && messages[i].message.role === 'assistant') {
485
const assistantMessage = messages[i].message as AssistantMessageContent;
486
if (assistantMessage.model !== SYNTHETIC_MODEL_ID) {
487
assistantMessages.push(assistantMessage);
488
if (assistantMessage.model) {
489
pendingResponseModelId = assistantMessage.model;
490
}
491
}
492
i++;
493
}
494
495
// Accumulate parts into the pending response
496
const parts = extractAssistantParts(assistantMessages, toolContext);
497
pendingResponseParts.push(...parts);
498
} else if (currentType === 'system') {
499
// System entries (e.g., "Conversation compacted") are appended as an
500
// additional markdown part in the pending response. We don't emit them
501
// as a separate ChatResponseTurn2 because the VS Code chat widget
502
// merges consecutive response turns without an intervening request,
503
// which causes the system text to lose its visual separation.
504
const msg = messages[i];
505
if (msg.message.role === 'system') {
506
const content = (msg.message as { role: 'system'; content: string }).content;
507
pendingResponseParts.push(
508
new vscode.ChatResponseMarkdownPart(new vscode.MarkdownString(`\n\n---\n\n*${content}*`))
509
);
510
}
511
i++;
512
} else {
513
// Skip unknown message types
514
i++;
515
}
516
}
517
518
// Finalize any remaining pending response
519
if (pendingResponseParts.length > 0) {
520
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));
521
}
522
523
return result;
524
}
525
526
// #endregion
527
528