Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.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 pathLib from 'path';6import * as vscode from 'vscode';7import { ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseMultiDiffPart, ChatResponseProgressPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatResult, ChatToolInvocationPart, MarkdownString, Uri } from 'vscode';8import { IGitService } from '../../../platform/git/common/gitService';9import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI';10import { getAuthorDisplayName } from '../vscode/copilotCodingAgentUtils';1112export interface SessionResponseLogChunk {13choices: Array<{14finish_reason?: 'tool_calls' | 'null' | (string & {});15delta: {16content?: string;17role: 'assistant' | (string & {});18tool_calls?: Array<{19function: {20arguments: string;21name: string;22};23id: string;24type: string;25index: number;26}>;27};28}>;29created: number;30id: string;31usage: {32completion_tokens: number;33prompt_tokens: number;34prompt_tokens_details: {35cached_tokens: number;36};37total_tokens: number;38};39model: string;40object: string;41}4243export interface ToolCall {44function: {45arguments: string;46name: 'bash' | 'reply_to_comment' | (string & {});47};48id: string;49type: string;50index: number;51}5253export interface AssistantDelta {54content?: string;55role: 'assistant' | (string & {});56tool_calls?: ToolCall[];57}5859export interface Choice {60finish_reason?: 'tool_calls' | (string & {});61delta: {62content?: string;63role: 'assistant' | (string & {});64tool_calls?: ToolCall[];65};66}6768export interface StrReplaceEditorToolData {69command: 'view' | 'edit' | string;70filePath?: string;71fileLabel?: string;72parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined };73viewRange?: { start: number; end: number };74}7576export namespace StrReplaceEditorToolData {77export function is(value: any): value is StrReplaceEditorToolData {78return value && (typeof value.command === 'string');79}80}8182export interface BashToolData {83commandLine: {84original: string;85};86language: 'bash';87}8889export interface ParsedToolCallDetails {90toolName: string;91invocationMessage: string;92pastTenseMessage?: string;93originMessage?: string;94toolSpecificData?: StrReplaceEditorToolData | BashToolData;95}9697export class ChatSessionContentBuilder {98constructor(99private type: string,100@IGitService private readonly _gitService: IGitService101) {102}103104public async buildSessionHistory(105problemStatementPromise: Promise<string | undefined>,106sessions: SessionInfo[],107pullRequest: PullRequestSearchItem,108getLogsForSession: (id: string) => Promise<string>,109initialReferences: Promise<vscode.ChatPromptReference[]>,110): Promise<Array<ChatRequestTurn | ChatResponseTurn2>> {111const history: Array<ChatRequestTurn | ChatResponseTurn2> = [];112113// Process all sessions concurrently and assemble results in order114const sessionResults = await Promise.all(115sessions.map(async (session, sessionIndex) => {116const [logs, problemStatement] = await Promise.all([getLogsForSession(session.id), sessionIndex === 0 ? problemStatementPromise : Promise.resolve(undefined)]);117118const turns: Array<ChatRequestTurn | ChatResponseTurn2> = [];119120// Create request turn with references for the first session121const references = sessionIndex === 0 ? Array.from(await initialReferences) : [];122turns.push(new ChatRequestTurn2(123problemStatement || session.name,124undefined, // command125references, // references126this.type,127[], // toolReferences128[],129undefined,130undefined,131undefined132));133134// Create the PR card right after problem statement for first session135if (sessionIndex === 0 && pullRequest.author && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {136const plaintextBody = pullRequest.body;137138const card = new vscode.ChatResponsePullRequestPart({ command: 'github.copilot.chat.openPullRequestReroute', title: vscode.l10n.t('View Pull Request {0}', `#${pullRequest.number}`), arguments: [pullRequest.number] }, pullRequest.title, plaintextBody, getAuthorDisplayName(pullRequest.author), `#${pullRequest.number}`);139const cardTurn = new vscode.ChatResponseTurn2([card], {}, this.type);140turns.push(cardTurn);141}142143const response = await this.createResponseTurn(pullRequest, logs, session);144if (response) {145turns.push(response);146}147148return { sessionIndex, turns };149})150);151152// Assemble results in correct order153sessionResults154.sort((a, b) => a.sessionIndex - b.sessionIndex)155.forEach(result => history.push(...result.turns));156157return history;158}159160private async createResponseTurn(pullRequest: PullRequestSearchItem, logs: string, session: SessionInfo): Promise<ChatResponseTurn2 | undefined> {161if (logs.trim().length > 0) {162return await this.parseSessionLogsIntoResponseTurn(pullRequest, logs, session);163} else if (session.state === 'in_progress' || session.state === 'queued') {164// For in-progress sessions without logs, create a placeholder response165const placeholderParts = [new ChatResponseProgressPart('Session is initializing...')];166const responseResult: ChatResult = {};167return new ChatResponseTurn2(placeholderParts, responseResult, this.type);168} else {169// For completed sessions without logs, add an empty response to maintain pairing170const emptyParts = [new ChatResponseMarkdownPart('_No logs available for this session_')];171const responseResult: ChatResult = {};172return new ChatResponseTurn2(emptyParts, responseResult, this.type);173}174}175176private async parseSessionLogsIntoResponseTurn(pullRequest: PullRequestSearchItem, logs: string, session: SessionInfo): Promise<ChatResponseTurn2 | undefined> {177try {178const logChunks = this.parseSessionLogs(logs);179const responseParts: Array<ChatResponseMarkdownPart | ChatToolInvocationPart | ChatResponseMultiDiffPart> = [];180181for (const chunk of logChunks) {182if (!chunk.choices || !Array.isArray(chunk.choices)) {183continue;184}185186for (const choice of chunk.choices) {187const delta = choice.delta;188if (delta.role === 'assistant') {189this.processAssistantDelta(delta, choice, pullRequest, responseParts);190}191192}193}194195if (responseParts.length > 0) {196const responseResult: ChatResult = {};197return new ChatResponseTurn2(responseParts, responseResult, this.type);198}199200return undefined;201} catch (error) {202return undefined;203}204}205206public parseSessionLogs(rawText: string): SessionResponseLogChunk[] {207const parts = rawText208.split(/\r?\n/)209.filter(part => part.startsWith('data: '))210.map(part => part.slice('data: '.length).trim())211.map(part => JSON.parse(part));212213return parts as SessionResponseLogChunk[];214}215216private processAssistantDelta(217delta: AssistantDelta,218choice: Choice,219pullRequest: PullRequestSearchItem,220responseParts: Array<ChatResponseMarkdownPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart>,221): string {222let currentResponseContent = '';223if (delta.role === 'assistant') {224const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : undefined;225// Handle special case for run_custom_setup_step226if (227choice.finish_reason === 'tool_calls' &&228toolCalls?.length &&229(toolCalls[0].function.name === 'run_custom_setup_step' || toolCalls[0].function.name === 'run_setup')230) {231const toolCall = toolCalls[0];232let args: { name?: string } = {};233try {234args = JSON.parse(toolCall.function.arguments);235} catch {236// fallback to empty args237}238239if (delta.content && delta.content.trim()) {240const toolPart = this.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content);241if (toolPart) {242responseParts.push(toolPart);243if (toolPart instanceof ChatResponseThinkingProgressPart) {244responseParts.push(new ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));245}246}247}248// Skip if content is empty (running state)249} else {250if (delta.content) {251if (!delta.content.startsWith('<pr_title>') && !delta.content.startsWith('<error>')) {252currentResponseContent += delta.content;253}254}255256const isError = delta.content?.startsWith('<error>');257if (toolCalls) {258// Add any accumulated content as markdown first259if (currentResponseContent.trim()) {260responseParts.push(new ChatResponseMarkdownPart(currentResponseContent.trim()));261currentResponseContent = '';262}263264for (const toolCall of toolCalls) {265const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || '');266if (toolPart) {267responseParts.push(toolPart);268if (toolPart instanceof ChatResponseThinkingProgressPart) {269responseParts.push(new ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));270}271}272}273274if (isError) {275const toolPart = new ChatToolInvocationPart('Command', 'command');276// Remove <error> at the start and </error> at the end277const cleaned = (delta.content ?? '').replace(/^\s*<error>\s*/i, '').replace(/\s*<\/error>\s*$/i, '');278toolPart.invocationMessage = cleaned;279toolPart.isError = true;280responseParts.push(toolPart);281}282} else {283const trimmedContent = currentResponseContent.trim();284if (trimmedContent) {285// TODO@rebornix @osortega validate if this is the only finish_reason for session end.286if (choice.finish_reason === 'stop') {287responseParts.push(new ChatResponseMarkdownPart(trimmedContent));288} else {289responseParts.push(new ChatResponseThinkingProgressPart(trimmedContent, '', { vscodeReasoningDone: true }));290}291currentResponseContent = '';292}293}294}295}296return currentResponseContent;297}298299public createToolInvocationPart(pullRequest: PullRequestSearchItem, toolCall: ToolCall, deltaContent: string = ''): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined {300if (!toolCall.function?.name || !toolCall.id) {301return undefined;302}303304// Hide reply_to_comment tool305if (toolCall.function.name === 'reply_to_comment') {306return undefined;307}308309const toolPart = new ChatToolInvocationPart(toolCall.function.name, toolCall.id);310toolPart.isComplete = true;311toolPart.isError = false;312toolPart.isConfirmed = true;313314try {315const toolDetails = this.parseToolCallDetails(toolCall, deltaContent);316toolPart.toolName = toolDetails.toolName;317318if (toolPart.toolName === 'think') {319return new ChatResponseThinkingProgressPart(toolDetails.invocationMessage);320}321322if (toolCall.function.name === 'bash') {323toolPart.invocationMessage = new MarkdownString(`\`\`\`bash\n${toolDetails.invocationMessage}\n\`\`\``);324} else {325toolPart.invocationMessage = new MarkdownString(toolDetails.invocationMessage);326}327328if (toolDetails.pastTenseMessage) {329toolPart.pastTenseMessage = new MarkdownString(toolDetails.pastTenseMessage);330}331if (toolDetails.originMessage) {332toolPart.originMessage = new MarkdownString(toolDetails.originMessage);333}334if (toolDetails.toolSpecificData) {335if (StrReplaceEditorToolData.is(toolDetails.toolSpecificData)) {336if ((toolDetails.toolSpecificData.command === 'view' || toolDetails.toolSpecificData.command === 'edit') && toolDetails.toolSpecificData.fileLabel) {337const currentRepository = this._gitService.activeRepository.get();338const uri = currentRepository?.rootUri ? Uri.file(pathLib.join(currentRepository.rootUri.fsPath, toolDetails.toolSpecificData.fileLabel)) : Uri.file(toolDetails.toolSpecificData.fileLabel);339toolPart.invocationMessage = new MarkdownString(`${toolPart.toolName} [](${uri.toString()})` + (toolDetails.toolSpecificData?.viewRange ? `, lines ${toolDetails.toolSpecificData.viewRange?.start} to ${toolDetails.toolSpecificData.viewRange?.end}` : ''));340toolPart.invocationMessage.supportHtml = true;341toolPart.pastTenseMessage = new MarkdownString(`${toolPart.toolName} [](${uri.toString()})` + (toolDetails.toolSpecificData?.viewRange ? `, lines ${toolDetails.toolSpecificData.viewRange?.start} to ${toolDetails.toolSpecificData.viewRange?.end}` : ''));342}343} else {344toolPart.toolSpecificData = toolDetails.toolSpecificData;345}346}347} catch (error) {348toolPart.toolName = toolCall.function.name || 'unknown';349toolPart.invocationMessage = new MarkdownString(`Tool: ${toolCall.function.name}`);350toolPart.isError = true;351}352353return toolPart;354}355356/**357* Convert absolute file path to relative file label358* File paths are absolute and look like: `/home/runner/work/repo/repo/<path>`359*/360private toFileLabel(file: string): string {361const parts = file.split('/');362return parts.slice(6).join('/');363}364365private parseRange(view_range: unknown): { start: number; end: number } | undefined {366if (!view_range) {367return undefined;368}369370if (!Array.isArray(view_range)) {371return undefined;372}373374if (view_range.length !== 2) {375return undefined;376}377378const start = view_range[0];379const end = view_range[1];380381if (typeof start !== 'number' || typeof end !== 'number') {382return undefined;383}384385return {386start,387end388};389}390391/**392* Parse diff content and extract file information393*/394private parseDiff(content: string): { content: string; fileA: string | undefined; fileB: string | undefined } | undefined {395const lines = content.split(/\r?\n/g);396let fileA: string | undefined;397let fileB: string | undefined;398399let startDiffLineIndex = -1;400for (let i = 0; i < lines.length; i++) {401const line = lines[i];402if (line.startsWith('diff --git')) {403const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);404if (match) {405fileA = match[1];406fileB = match[2];407}408} else if (line.startsWith('@@ ')) {409startDiffLineIndex = i + 1;410break;411}412}413if (startDiffLineIndex < 0) {414return undefined;415}416417return {418content: lines.slice(startDiffLineIndex).join('\n'),419fileA: typeof fileA === 'string' ? '/' + fileA : undefined,420fileB: typeof fileB === 'string' ? '/' + fileB : undefined421};422}423424/**425* Parse tool call arguments and return normalized tool details426*/427private parseToolCallDetails(428toolCall: {429function: { name: string; arguments: string };430id: string;431type: string;432index: number;433},434content: string435): ParsedToolCallDetails {436// Parse arguments once with graceful fallback437let args: { command?: string; path?: string; prDescription?: string; commitMessage?: string; view_range?: unknown } = {};438try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch { /* ignore */ }439440const name = toolCall.function.name;441442// Small focused helpers to remove duplication while preserving behavior443const buildReadDetails = (filePath: string | undefined, parsedRange: { start: number; end: number } | undefined, opts?: { parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined } }): ParsedToolCallDetails => {444const fileLabel = filePath && this.toFileLabel(filePath);445if (fileLabel === undefined || fileLabel === '') {446return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' };447}448const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : '';449// Default helper returns bracket variant (used for generic view). Plain variant handled separately for str_replace_editor non-diff.450return {451toolName: 'Read',452invocationMessage: `Read [](${fileLabel})${rangeSuffix}`,453pastTenseMessage: `Read [](${fileLabel})${rangeSuffix}`,454toolSpecificData: {455command: 'view',456filePath: filePath,457fileLabel: fileLabel,458parsedContent: opts?.parsedContent,459viewRange: parsedRange460}461};462};463464const buildEditDetails = (filePath: string | undefined, command: string = 'edit', parsedRange: { start: number; end: number } | undefined, opts?: { defaultName?: string }): ParsedToolCallDetails => {465const fileLabel = filePath && this.toFileLabel(filePath);466const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : '';467let invocationMessage: string;468let pastTenseMessage: string;469if (fileLabel) {470invocationMessage = `Edit [](${fileLabel})${rangeSuffix}`;471pastTenseMessage = `Edit [](${fileLabel})${rangeSuffix}`;472} else {473if (opts?.defaultName === 'Create') {474invocationMessage = pastTenseMessage = `Create File ${filePath}`;475} else {476invocationMessage = pastTenseMessage = (opts?.defaultName || 'Edit');477}478invocationMessage += rangeSuffix;479pastTenseMessage += rangeSuffix;480}481482return {483toolName: opts?.defaultName || 'Edit',484invocationMessage,485pastTenseMessage,486toolSpecificData: fileLabel ? {487command: command || (opts?.defaultName === 'Create' ? 'create' : (command || 'edit')),488filePath: filePath,489fileLabel: fileLabel,490viewRange: parsedRange491} : undefined492};493};494495const buildStrReplaceDetails = (filePath: string | undefined): ParsedToolCallDetails => {496const fileLabel = filePath && this.toFileLabel(filePath);497const message = fileLabel ? `Edit [](${fileLabel})` : `Edit ${filePath}`;498return {499toolName: 'Edit',500invocationMessage: message,501pastTenseMessage: message,502toolSpecificData: fileLabel ? { command: 'str_replace', filePath, fileLabel } : undefined503};504};505506const buildCreateDetails = (filePath: string | undefined): ParsedToolCallDetails => {507const fileLabel = filePath && this.toFileLabel(filePath);508const message = fileLabel ? `Create [](${fileLabel})` : `Create File ${filePath}`;509return {510toolName: 'Create',511invocationMessage: message,512pastTenseMessage: message,513toolSpecificData: fileLabel ? { command: 'create', filePath, fileLabel } : undefined514};515};516517const buildBashDetails = (bashArgs: typeof args, contentStr: string): ParsedToolCallDetails => {518const command = bashArgs.command ? `$ ${bashArgs.command}` : undefined;519const bashContent = [command, contentStr].filter(Boolean).join('\n');520521const MAX_CONTENT_LENGTH = 200;522let displayContent = bashContent;523if (bashContent && bashContent.length > MAX_CONTENT_LENGTH) {524// Check if content contains EOF marker (heredoc pattern)525const hasEOF = (bashContent && /<<\s*['"]?EOF['"]?/.test(bashContent));526if (hasEOF) {527// show the command line up to EOL528const firstLineEnd = bashContent.indexOf('\n');529if (firstLineEnd > 0) {530const firstLine = bashContent.substring(0, firstLineEnd);531const remainingChars = bashContent.length - firstLineEnd - 1;532displayContent = firstLine + `\n... [${remainingChars} characters of heredoc content]`;533} else {534displayContent = bashContent;535}536} else {537displayContent = bashContent.substring(0, MAX_CONTENT_LENGTH) + `\n... [${bashContent.length - MAX_CONTENT_LENGTH} more characters]`;538}539}540541const details: ParsedToolCallDetails = { toolName: 'Run Bash command', invocationMessage: bashContent || 'Run Bash command' };542if (bashArgs.command) { details.toolSpecificData = { commandLine: { original: displayContent ?? '' }, language: 'bash' }; }543return details;544};545546switch (name) {547case 'str_replace_editor': {548if (args.command === 'view') {549const parsedContent = this.parseDiff(content);550const parsedRange = this.parseRange(args.view_range);551if (parsedContent) {552const file = parsedContent.fileA ?? parsedContent.fileB;553const fileLabel = file && this.toFileLabel(file);554if (fileLabel === '') {555return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' };556} else if (fileLabel === undefined) {557return { toolName: 'Read', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' };558} else {559const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : '';560return {561toolName: 'Read',562invocationMessage: `Read [](${fileLabel})${rangeSuffix}`,563pastTenseMessage: `Read [](${fileLabel})${rangeSuffix}`,564toolSpecificData: { command: 'view', filePath: file, fileLabel, parsedContent, viewRange: parsedRange }565};566}567}568// No diff parsed: use PLAIN (non-bracket) variant for str_replace_editor views569const plainRange = this.parseRange(args.view_range);570const fp = args.path; const fl = fp && this.toFileLabel(fp);571if (fl === undefined || fl === '') {572return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' };573}574const suffix = plainRange ? `, lines ${plainRange.start} to ${plainRange.end}` : '';575return {576toolName: 'Read',577invocationMessage: `Read ${fl}${suffix}`,578pastTenseMessage: `Read ${fl}${suffix}`,579toolSpecificData: { command: 'view', filePath: fp, fileLabel: fl, viewRange: plainRange }580};581}582return buildEditDetails(args.path, args.command, this.parseRange(args.view_range));583}584case 'str_replace':585return buildStrReplaceDetails(args.path);586case 'create':587return buildCreateDetails(args.path);588case 'view':589return buildReadDetails(args.path, this.parseRange(args.view_range)); // generic view always bracket variant590case 'think': {591const thought = (args as unknown as { thought?: string }).thought || content || 'Thought';592return { toolName: 'think', invocationMessage: thought };593}594case 'report_progress': {595const details: ParsedToolCallDetails = { toolName: 'Progress Update', invocationMessage: `${args.prDescription}` || content || 'Progress Update' };596if (args.commitMessage) { details.originMessage = `Commit: ${args.commitMessage}`; }597return details;598}599case 'bash':600return buildBashDetails(args, content);601case 'read_bash':602return { toolName: 'read_bash', invocationMessage: 'Read logs from Bash session' };603case 'stop_bash':604return { toolName: 'stop_bash', invocationMessage: 'Stop Bash session' };605case 'edit':606return buildEditDetails(args.path, args.command, undefined);607default:608return { toolName: name || 'unknown', invocationMessage: content || name || 'unknown' };609}610}611}612613614