Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/chatHistoryBuilder.ts
13399 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*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import { coalesce } from '../../../util/vs/base/common/arrays';7import { URI } from '../../../util/vs/base/common/uri';8import { ChatReferenceBinaryData, ChatRequestTurn2 } from '../../../vscodeTypes';9import { tryParseClaudeModelId } from '../claude/node/claudeModelId';10import { completeToolInvocation, createFormattedToolInvocation } from '../claude/common/toolInvocationFormatter';11import { AssistantMessageContent, ContentBlock, IClaudeCodeSession, ImageBlock, ISubagentSession, StoredMessage, SYNTHETIC_MODEL_ID, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock } from '../claude/node/sessionParser/claudeSessionSchema';1213// #region Types1415interface ToolContext {16unprocessedToolCalls: Map<string, ContentBlock>;17pendingToolInvocations: Map<string, vscode.ChatToolInvocationPart>;18}1920// #endregion2122// #region Type Guards2324function isTextBlock(block: ContentBlock): block is TextBlock {25return block.type === 'text';26}2728function isThinkingBlock(block: ContentBlock): block is ThinkingBlock {29return block.type === 'thinking';30}3132function isToolUseBlock(block: ContentBlock): block is ToolUseBlock {33return block.type === 'tool_use';34}3536function isToolResultBlock(block: ContentBlock): block is ToolResultBlock {37return block.type === 'tool_result';38}3940function isImageBlock(block: ContentBlock): block is ImageBlock {41return block.type === 'image';42}4344// #endregion4546// #region Command Message Helpers4748/**49* Regex patterns for Claude Code slash command XML tags in user message content.50* These are emitted by the Claude Code CLI when the user runs a local command51* (e.g., /compact, /init). The messages contain structured XML tags:52* - <command-name>/compact</command-name>53* - <command-message>compact</command-message>54* - <command-args>...</command-args>55* - <local-command-stdout>...</local-command-stdout>56*/57const COMMAND_NAME_PATTERN = /<command-name>([\s\S]*?)<\/command-name>/;58const COMMAND_STDOUT_PATTERN = /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/;5960/**61* Scans user message contents for slash command patterns and extracts62* the command name and optional stdout output.63*64* Returns undefined if no command patterns are found.65*/66function extractCommandInfo(contents: readonly (string | ContentBlock[])[]): { commandName: string; stdout?: string } | undefined {67let commandName: string | undefined;68let stdout: string | undefined;6970for (const content of contents) {71if (typeof content === 'string') {72const nameMatch = COMMAND_NAME_PATTERN.exec(content);73if (nameMatch) {74commandName ??= nameMatch[1].trim();75}76const stdoutMatch = COMMAND_STDOUT_PATTERN.exec(content);77if (stdoutMatch) {78stdout ??= stdoutMatch[1].trim();79}80} else {81for (const block of content) {82if (isTextBlock(block)) {83const nameMatch = COMMAND_NAME_PATTERN.exec(block.text);84if (nameMatch) {85commandName ??= nameMatch[1].trim();86}87const stdoutMatch = COMMAND_STDOUT_PATTERN.exec(block.text);88if (stdoutMatch) {89stdout ??= stdoutMatch[1].trim();90}91}92}93}94}9596if (commandName !== undefined) {97return { commandName, stdout };98}99return undefined;100}101102// #endregion103104// #region Text Content Helpers105106/**107* Checks if a text block contains a system-reminder tag.108* System-reminders are stored in separate content blocks and should not be rendered.109*/110function isSystemReminderBlock(text: string): boolean {111return text.includes('<system-reminder>');112}113114/**115* Strips <system-reminder> tags and their content from a string.116* Used for backwards compatibility with legacy sessions where system-reminders117* were concatenated with user text in a single string.118*119* TODO: Remove this function after a few releases (added in 0.38.x) once legacy120* sessions with concatenated system-reminders are no longer common.121*/122function stripSystemReminders(text: string): string {123return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, '');124}125126/**127* Extracts visible text content from a user message, filtering out system reminders.128*/129function extractTextContent(content: string | ContentBlock[]): string {130if (typeof content === 'string') {131// TODO: Remove this branch when stripSystemReminders is removed (legacy compat)132return stripSystemReminders(content);133}134135// For array content (new format), filter out entire blocks that are system-reminders136return content137.filter(isTextBlock)138.filter(block => !isSystemReminderBlock(block.text))139.map(block => block.text)140.join('');141}142143// #endregion144145// #region Tool Result Processing146147/**148* Processes tool result blocks from a user message, matching them to pending149* tool invocations and marking them as complete.150*/151function processToolResults(content: string | ContentBlock[], toolContext: ToolContext): void {152if (typeof content === 'string') {153return;154}155156for (const block of content) {157if (isToolResultBlock(block)) {158const toolUse = toolContext.unprocessedToolCalls.get(block.tool_use_id);159if (toolUse && isToolUseBlock(toolUse)) {160toolContext.unprocessedToolCalls.delete(block.tool_use_id);161const pendingInvocation = toolContext.pendingToolInvocations.get(block.tool_use_id);162if (pendingInvocation) {163pendingInvocation.isComplete = true;164pendingInvocation.isConfirmed = true;165pendingInvocation.isError = block.is_error;166// Populate tool output for display in chat UI167completeToolInvocation(toolUse, block, pendingInvocation);168toolContext.pendingToolInvocations.delete(block.tool_use_id);169}170}171}172}173}174175// #endregion176177// #region Image Reference Extraction178179/**180* Extracts image blocks from user message contents and converts them to181* ChatPromptReference objects.182*183* - Base64 images become ChatReferenceBinaryData values (binary data for display).184* - URL images become URI values (the API stored a URL rather than inline data).185*/186function extractImageReferences(contents: readonly (string | ContentBlock[])[]): vscode.ChatPromptReference[] {187const references: vscode.ChatPromptReference[] = [];188let imageIndex = 0;189for (const content of contents) {190if (typeof content === 'string') {191continue;192}193for (const block of content) {194if (!isImageBlock(block)) {195continue;196}197const id = `image-${imageIndex + 1}`;198if (block.source.type === 'base64') {199const source = block.source;200// NOTE: The API does not give us any metadata about the image beyond the media type, so201// we use a generic name and the media type as the MIME type for the binary reference.202references.push({203id,204name: id,205value: new ChatReferenceBinaryData(206source.media_type,207() => Promise.resolve(Buffer.from(source.data, 'base64'))208),209});210imageIndex++;211} else if (block.source.type === 'url') {212references.push({213id,214name: id,215value: URI.parse(block.source.url),216});217imageIndex++;218}219}220}221return references;222}223224// #endregion225226// #region Turn Extraction227228/**229* Extracts a request turn from user message contents, ignoring tool results.230* Returns undefined if the messages contain only tool results or system reminders.231*/232function extractUserRequest(contents: readonly (string | ContentBlock[])[], messageId: string, modelId: string | undefined): vscode.ChatRequestTurn2 | undefined {233const textParts: string[] = [];234for (const content of contents) {235const text = extractTextContent(content);236if (text.trim()) {237textParts.push(text);238}239}240241const combinedText = textParts.join('\n\n');242const imageReferences = extractImageReferences(contents);243244// If no visible text and no images, don't create a request turn245if (!combinedText.trim() && imageReferences.length === 0) {246return;247}248249// If the message indicates it was interrupted, skip it250if (combinedText === '[Request interrupted by user]') {251return;252}253254return new ChatRequestTurn2(combinedText, undefined, imageReferences, '', [], undefined, messageId, modelId, undefined);255}256257/**258* Extracts response parts from consecutive assistant messages.259*/260function extractAssistantParts(messages: readonly AssistantMessageContent[], toolContext: ToolContext): (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] {261const allParts: (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] = [];262263for (const message of messages) {264const parts = coalesce(message.content.map(block => {265if (isTextBlock(block)) {266return new vscode.ChatResponseMarkdownPart(new vscode.MarkdownString(block.text));267} else if (isThinkingBlock(block)) {268return new vscode.ChatResponseThinkingProgressPart(block.thinking);269} else if (isToolUseBlock(block)) {270toolContext.unprocessedToolCalls.set(block.id, block);271const toolInvocation = createFormattedToolInvocation(block);272if (toolInvocation) {273toolContext.pendingToolInvocations.set(block.id, toolInvocation);274}275return toolInvocation;276}277}));278allParts.push(...parts);279}280281return allParts;282}283284// #endregion285286// #region Subagent Tool Extraction287288/**289* Builds a map from parentToolUseId to ISubagentSession for quick lookup.290*/291function buildSubagentMap(subagents: readonly ISubagentSession[]): Map<string, ISubagentSession> {292const map = new Map<string, ISubagentSession>();293for (const subagent of subagents) {294if (subagent.parentToolUseId) {295map.set(subagent.parentToolUseId, subagent);296}297}298return map;299}300301/**302* Extracts tool invocation parts from a subagent session's messages.303* These are the tool calls made by the subagent during its execution.304* Each tool invocation has subAgentInvocationId set to associate it with the parent Task.305*/306function extractSubagentToolParts(307subagent: ISubagentSession,308taskToolUseId: string309): vscode.ChatToolInvocationPart[] {310const toolContext: ToolContext = {311unprocessedToolCalls: new Map(),312pendingToolInvocations: new Map()313};314const parts: vscode.ChatToolInvocationPart[] = [];315316for (const message of subagent.messages) {317if (message.type === 'assistant') {318const assistantContent = message.message as AssistantMessageContent;319for (const block of assistantContent.content) {320if (isToolUseBlock(block)) {321toolContext.unprocessedToolCalls.set(block.id, block);322const toolInvocation = createFormattedToolInvocation(block, true);323if (toolInvocation) {324toolInvocation.subAgentInvocationId = taskToolUseId;325toolContext.pendingToolInvocations.set(block.id, toolInvocation);326parts.push(toolInvocation);327}328}329}330} else if (message.type === 'user') {331const content = message.message.content;332if (typeof content !== 'string') {333processToolResults(content, toolContext);334}335}336}337338return parts;339}340341// #endregion342343// #region Model ID Resolution344345/**346* Looks ahead from a given index in the message array to find the model ID347* from the first non-synthetic assistant message. Converts SDK model IDs348* to endpoint format using {@link tryParseClaudeModelId}.349*350* @param messages The session's stored messages351* @param startIndex The index to start looking from (typically after user messages)352* @returns The endpoint model ID, or undefined if not found353*/354function findModelIdForRequest(355messages: readonly StoredMessage[],356startIndex: number,357): string | undefined {358for (let j = startIndex; j < messages.length; j++) {359const msg = messages[j];360if (msg.type === 'assistant' && msg.message.role === 'assistant') {361const assistantMsg = msg.message as AssistantMessageContent;362if (assistantMsg.model && assistantMsg.model !== SYNTHETIC_MODEL_ID) {363return tryParseClaudeModelId(assistantMsg.model)?.toEndpointModelId() ?? assistantMsg.model;364}365}366}367return undefined;368}369370// #endregion371372/**373* Converts a Claude Code session into VS Code chat history turns.374*375* In the Anthropic API, tool results are sent as user messages, so a single376* agentic turn (assistant calls tools, gets results, calls more tools, etc.)377* appears as alternating assistant/user messages in the JSONL. VS Code's chat378* API expects all of that to be a single ChatResponseTurn2, so we accumulate379* response parts across tool-result boundaries and only finalize a response380* when we encounter a user message with actual text (a new user request).381*382* @param session The Claude Code session to convert383* @param getModelDetails Optional lookup that returns the display string for a Claude384* model id (as it appears on stored assistant messages).385*/386export function buildChatHistory(session: IClaudeCodeSession, getModelDetails?: (modelId: string) => string | undefined): (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] {387const result: (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] = [];388const toolContext: ToolContext = {389unprocessedToolCalls: new Map(),390pendingToolInvocations: new Map()391};392let i = 0;393const messages = session.messages;394let pendingResponseParts: (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] = [];395// Tracks the most recent assistant model id observed in the current pending response396// group so we can populate `ChatResponseTurn2.result.details` when finalizing it.397let pendingResponseModelId: string | undefined;398const makeResponseResult = (modelId: string | undefined): vscode.ChatResult => {399if (!modelId || !getModelDetails) {400return {};401}402const details = getModelDetails(modelId);403return details ? { details } : {};404};405406// Build a map from parentToolUseId to subagent for quick lookup407const subagentMap = buildSubagentMap(session.subagents);408409while (i < messages.length) {410const currentType = messages[i].type;411const currentMessageId = messages[i].uuid;412if (currentType === 'user') {413// Collect all consecutive user messages (preserving the full StoredMessage for metadata)414const userMessages: StoredMessage[] = [];415while (i < messages.length && messages[i].type === 'user' && messages[i].message.role === 'user') {416userMessages.push(messages[i]);417i++;418}419420const userContents = userMessages.map(m => m.message.content as string | ContentBlock[]);421422// Always process tool results to update pending tool invocations423for (const content of userContents) {424processToolResults(content, toolContext);425}426427// After processing tool results, inject subagent tool calls for subagents correlated via parentToolUseId.428// Each subagent's parentToolUseId links it to the Agent or legacy Task tool_use that spawned it.429// We match tool_result blocks in user messages to those subagents via tool_use_id.430for (const content of userContents) {431if (typeof content === 'string') {432continue;433}434for (const block of content) {435if (isToolResultBlock(block)) {436const subagent = subagentMap.get(block.tool_use_id);437if (subagent) {438const subagentParts = extractSubagentToolParts(subagent, block.tool_use_id);439pendingResponseParts.push(...subagentParts);440}441}442}443}444445// Check for slash command patterns (e.g., /compact, /init)446const commandInfo = extractCommandInfo(userContents);447const modelId = findModelIdForRequest(messages, i);448if (commandInfo) {449// Finalize any pending response first450if (pendingResponseParts.length > 0) {451result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));452pendingResponseParts = [];453pendingResponseModelId = undefined;454}455// Emit the command as a request turn456result.push(new ChatRequestTurn2(commandInfo.commandName, undefined, [], '', [], undefined, currentMessageId, modelId, undefined));457// Emit stdout as a response turn if present458if (commandInfo.stdout) {459result.push(new vscode.ChatResponseTurn2(460[new vscode.ChatResponseMarkdownPart(new vscode.MarkdownString(commandInfo.stdout))],461{},462''463));464}465} else {466// Check if there's actual user text (not just tool results)467const requestTurn = extractUserRequest(userContents, currentMessageId, modelId);468if (requestTurn) {469// Real user message — finalize any pending response first470if (pendingResponseParts.length > 0) {471result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));472pendingResponseParts = [];473pendingResponseModelId = undefined;474}475result.push(requestTurn);476}477// Otherwise this was a tool-result-only message — don't break the response grouping478}479} else if (currentType === 'assistant') {480// Collect all consecutive assistant messages, skipping synthetic ones481// (e.g., "No response requested." from abort)482const assistantMessages: AssistantMessageContent[] = [];483while (i < messages.length && messages[i].type === 'assistant' && messages[i].message.role === 'assistant') {484const assistantMessage = messages[i].message as AssistantMessageContent;485if (assistantMessage.model !== SYNTHETIC_MODEL_ID) {486assistantMessages.push(assistantMessage);487if (assistantMessage.model) {488pendingResponseModelId = assistantMessage.model;489}490}491i++;492}493494// Accumulate parts into the pending response495const parts = extractAssistantParts(assistantMessages, toolContext);496pendingResponseParts.push(...parts);497} else if (currentType === 'system') {498// System entries (e.g., "Conversation compacted") are appended as an499// additional markdown part in the pending response. We don't emit them500// as a separate ChatResponseTurn2 because the VS Code chat widget501// merges consecutive response turns without an intervening request,502// which causes the system text to lose its visual separation.503const msg = messages[i];504if (msg.message.role === 'system') {505const content = (msg.message as { role: 'system'; content: string }).content;506pendingResponseParts.push(507new vscode.ChatResponseMarkdownPart(new vscode.MarkdownString(`\n\n---\n\n*${content}*`))508);509}510i++;511} else {512// Skip unknown message types513i++;514}515}516517// Finalize any remaining pending response518if (pendingResponseParts.length > 0) {519result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));520}521522return result;523}524525// #endregion526527528