Files
vscode/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
T
924d37b459 Make coding agent voice aware for better voice experience (#328217)
* Add voice-aware progress to Copilot Agent mode

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Gate voice-aware agent progress

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Mir Imad Ahmed <mirimadahmed@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-31 16:24:24 +00:00

3331 lines
132 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { Attachment, SendOptions, SessionOptions, ToolExecutionCompleteEvent, ToolExecutionStartEvent } from '@github/copilot/sdk';
import * as l10n from '@vscode/l10n';
import * as cp from 'child_process';
import * as crypto from 'crypto';
import type * as vscode from 'vscode';
import type { ChatParticipantToolToken } from 'vscode';
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
import { IChatQuotaService, QuotaSnapshot, QuotaSnapshots } from '../../../../platform/chat/common/chatQuotaService';
import { getQuotaMessageForPlan } from '../../../../platform/chat/common/commonTypes';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { IGitService } from '../../../../platform/git/common/gitService';
import { PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService';
import { ILogService } from '../../../../platform/log/common/logService';
import { GenAiMetrics } from '../../../../platform/otel/common/genAiMetrics';
import { CopilotChatAttr, GenAiAttr, GenAiOperationName, GenAiProviderName, IOTelService, ISpanHandle, resolveWorkspaceOTelMetadata, SpanKind, SpanStatusCode, TraceContext, truncateForOTel, workspaceMetadataToOTelAttributes } from '../../../../platform/otel/common/index';
import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken';
import { IRequestLogger, LoggedRequestKind } from '../../../../platform/requestLogger/common/requestLogger';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
import { PromptTokenCategory, PromptTokenLabel } from '../../../../platform/tokenizer/node/promptTokenDetails';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { raceCancellation } from '../../../../util/vs/base/common/async';
import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
import { Codicon } from '../../../../util/vs/base/common/codicons';
import { Emitter } from '../../../../util/vs/base/common/event';
import { createSingleCallFunction } from '../../../../util/vs/base/common/functional';
import { DisposableStore, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';
import { truncate } from '../../../../util/vs/base/common/strings';
import { ThemeIcon } from '../../../../util/vs/base/common/themables';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatSessionStatus, ChatToolInvocationPart, EventEmitter, MarkdownString, Uri } from '../../../../vscodeTypes';
import { IToolsService } from '../../../tools/common/toolsService';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { ExternalEditTracker } from '../../common/externalEditTracker';
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';
import { clearTodoList, enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, stripReminders, ToolCall, updateTodoListFromSqlItems } from '../common/copilotCLITools';
import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext } from '../common/pendingRequestContext';
import { LocalSession, Session, SessionIdForCLI } from '../common/utils';
import { getCopilotCLISessionDir } from './cliHelpers';
import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
import { ICopilotCLIImageSupport } from './copilotCLIImageSupport';
import { handleExitPlanMode } from './exitPlanModeHandler';
import { type McCommand, type McEvent, type McSessionCreateResult, MissionControlApiClient } from './missionControlApiClient';
import { handleMcpPermission, handleReadPermission, handleShellPermission, handleWritePermission, type PermissionRequest, type PermissionRequestResult, showInteractivePermissionPrompt } from './permissionHelpers';
import { TodoSqlQuery } from './todoSqlQuery';
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from './userInputHelpers';
/**
* Known commands that can be sent to a CopilotCLI session instead of a free-form prompt.
*/
export type CopilotCLICommand = 'compact' | 'plan' | 'fleet' | 'remote';
/**
* The set of all known CopilotCLI commands. Used by callers that need to
* distinguish a slash-command from a regular prompt at runtime.
*/
export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'plan', 'fleet', 'remote'] as const;
export class CopilotCLIQuotaExceededError extends Error {
constructor(message: string) {
super(message);
this.name = 'CopilotCLIQuotaExceededError';
}
}
/**
* Shared Mission Control state keyed by SDK session ID.
* CopilotCLISession instances are recreated per request, so MC state
* must be stored externally to persist across turns.
*/
interface McSharedState {
mcSessionId: string;
mcFrontendUrl?: string;
mcMode?: MissionControlMode;
mcEventBuffer: McEvent[];
mcCompletedCommandIds: string[];
mcPendingPermissionRequests: Map<string, { resolve(result: PermissionRequestResult): void }>;
mcPendingUserInputRequests?: Set<McPendingUserInputRequest>;
mcFlushInterval: ReturnType<typeof setInterval> | undefined;
mcPollInterval: ReturnType<typeof setInterval> | undefined;
mcLastEventId: string | null;
mcLastSubmitAttemptTimeMs: number;
mcProcessedCommandIds: Set<string>;
mcPendingCommandCompletionIds?: Set<string>;
/** Reference to the SDK session for steering from the command poller. */
mcSdkSession: Session;
/** Dispose function for the persistent on('*') listener for MC events. */
mcEventListenerDispose: (() => void) | undefined;
/** VS Code session resource URI for routing steering through the chat UI. */
mcSessionResource: import('vscode').Uri;
}
const mcStateBySessionId = new Map<string, McSharedState>();
class CopilotCLIResponseStreamRouter {
private _stream: vscode.ChatResponseStream | undefined;
private readonly _routedStream: vscode.ChatResponseStream = {
markdown: (value: string | vscode.MarkdownString): void => { this._call('markdown', [value]); },
anchor: (value: vscode.Uri | vscode.Location, title?: string): void => { this._call('anchor', [value, title]); },
button: (command: vscode.Command): void => { this._call('button', [command]); },
filetree: (value: vscode.ChatResponseFileTree[], baseUri: vscode.Uri): void => { this._call('filetree', [value, baseUri]); },
progress: (value: string, task?: (progress: vscode.Progress<vscode.ChatResponseWarningPart | vscode.ChatResponseReferencePart>) => Thenable<string | void>): void => { this._call('progress', [value, task]); },
reference: (value: vscode.Uri | vscode.Location | { variableName: string; value?: vscode.Uri | vscode.Location }, iconPath?: vscode.Uri | vscode.ThemeIcon | { light: vscode.Uri; dark: vscode.Uri }): void => { this._call('reference', [value, iconPath]); },
push: (part: vscode.ExtendedChatResponsePart): void => { this._call('push', [part]); },
thinkingProgress: (thinkingDelta: vscode.ThinkingDelta): void => { this._call('thinkingProgress', [thinkingDelta]); },
hookProgress: (hookType: vscode.ChatHookType, stopReason?: string, systemMessage?: string): void => { this._call('hookProgress', [hookType, stopReason, systemMessage]); },
voiceProgress: (id: string, value: string): void => { this._call('voiceProgress', [id, value]); },
textEdit: (target: vscode.Uri, editsOrDone: vscode.TextEdit | vscode.TextEdit[] | true): void => { this._call('textEdit', [target, editsOrDone]); },
notebookEdit: (target: vscode.Uri, editsOrDone: vscode.NotebookEdit | vscode.NotebookEdit[] | true): void => { this._call('notebookEdit', [target, editsOrDone]); },
workspaceEdit: (edits: vscode.ChatWorkspaceFileEdit[]): void => { this._call('workspaceEdit', [edits]); },
externalEdit: (target: vscode.Uri | vscode.Uri[], callback: () => Thenable<unknown>): Thenable<string> => this._call('externalEdit', [target, createSingleCallFunction(callback)]) as Thenable<string>,
markdownWithVulnerabilities: (value: string | vscode.MarkdownString, vulnerabilities: vscode.ChatVulnerability[]): void => { this._call('markdownWithVulnerabilities', [value, vulnerabilities]); },
codeblockUri: (uri: vscode.Uri, isEdit?: boolean): void => { this._call('codeblockUri', [uri, isEdit]); },
confirmation: (title: string, message: string | vscode.MarkdownString, data: unknown, buttons?: string[]): void => { this._call('confirmation', [title, message, data, buttons]); },
questionCarousel: (questions: vscode.ChatQuestion[], allowSkip?: boolean): Thenable<Record<string, unknown> | undefined> => this._call('questionCarousel', [questions, allowSkip]) as Thenable<Record<string, unknown> | undefined>,
warning: (message: string | vscode.MarkdownString): void => { this._call('warning', [message]); },
info: (message: string | vscode.MarkdownString): void => { this._call('info', [message]); },
reference2: (value: vscode.Uri | vscode.Location | string | { variableName: string; value?: vscode.Uri | vscode.Location }, iconPath?: vscode.Uri | vscode.ThemeIcon | { light: vscode.Uri; dark: vscode.Uri }, options?: { status?: { description: string; kind: vscode.ChatResponseReferencePartStatusKind } }): void => { this._call('reference2', [value, iconPath, options]); },
codeCitation: (value: vscode.Uri, license: string, snippet: string): void => { this._call('codeCitation', [value, license, snippet]); },
beginToolInvocation: (toolCallId: string, toolName: string, streamData?: vscode.ChatToolInvocationStreamData & { subagentInvocationId?: string }): void => { this._call('beginToolInvocation', [toolCallId, toolName, streamData]); },
updateToolInvocation: (toolCallId: string, streamData: vscode.ChatToolInvocationStreamData): void => { this._call('updateToolInvocation', [toolCallId, streamData]); },
clearToPreviousToolInvocation: (reason: vscode.ChatResponseClearToPreviousToolInvocationReason): void => { this._call('clearToPreviousToolInvocation', [reason]); },
usage: (usage: vscode.ChatResultUsage): void => { this._call('usage', [usage]); },
};
private static readonly _closedStreamErrorFragment = 'Response stream has been closed'.toLowerCase();
constructor(
private readonly _logService: ILogService,
private readonly _sessionId: string,
) { }
get stream(): vscode.ChatResponseStream {
return this._routedStream;
}
attach(stream: vscode.ChatResponseStream): IDisposable {
this._stream = stream;
return toDisposable(() => {
if (this._stream === stream) {
this._stream = undefined;
}
});
}
private static _isClosedStreamError(error: unknown): boolean {
if (!error) {
return false;
}
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes(CopilotCLIResponseStreamRouter._closedStreamErrorFragment);
}
private _call(method: string, args: unknown[]): unknown {
const stream = this._stream;
if (!stream) {
return this._fallback(method, args);
}
const fn = (stream as unknown as Record<string, unknown>)[method];
if (typeof fn !== 'function') {
return this._fallback(method, args);
}
try {
const result = fn.apply(stream, args);
if (method === 'externalEdit' || method === 'questionCarousel') {
return Promise.resolve(result).catch(error => this._handleCallError(error, method, args, stream));
}
return result;
} catch (error) {
return this._handleCallError(error, method, args, stream);
}
}
private _handleCallError(error: unknown, method: string, args: unknown[], stream: vscode.ChatResponseStream): unknown {
if (CopilotCLIResponseStreamRouter._isClosedStreamError(error)) {
if (this._stream === stream) {
this._stream = undefined;
}
this._logService.trace(`[CopilotCLISession] Dropping ${method} for closed response stream in session ${this._sessionId}`);
return this._fallback(method, args);
}
throw error;
}
private _fallback(method: string, args: unknown[]): unknown {
if (method === 'externalEdit') {
const callback = args[1];
if (typeof callback === 'function') {
// The callback is the caller's proceed signal; dropping it would stall the tool when only the UI stream is gone.
return Promise.resolve().then(() => (callback as () => Thenable<unknown>)()).then(() => '');
}
return Promise.resolve('');
}
if (method === 'questionCarousel') {
return Promise.resolve(undefined);
}
return undefined;
}
}
const MISSION_CONTROL_KEEPALIVE_INTERVAL_MS = 10_000;
type MissionControlMode = 'plan' | 'autopilot' | 'interactive';
interface McModeCommandData {
readonly mode?: string;
}
interface McPermissionResponseCommandData {
readonly promptId?: string;
readonly approved?: boolean;
readonly scope?: 'once' | 'session';
}
interface UserInputResponse {
readonly answer: string;
readonly wasFreeform: boolean;
}
interface McPendingUserInputRequest {
readonly requestId: string;
readonly toolCallId?: string;
resolve(result: UserInputResponse | undefined): void;
}
interface McAskUserResponsePayload {
readonly requestId?: string;
readonly promptId?: string;
readonly toolCallId?: string;
readonly answer?: string;
readonly wasFreeform?: boolean;
readonly freeText?: string | null;
readonly selected?: readonly string[];
readonly skipped?: boolean;
readonly response?: {
readonly answer?: string;
readonly wasFreeform?: boolean;
readonly freeText?: string | null;
readonly selected?: readonly string[];
readonly skipped?: boolean;
};
}
const skippedMissionControlEventTypes = new Set([
'assistant.message_delta',
'assistant.streaming_delta',
'session.shutdown',
'session.error',
'session.usage_info',
'assistant.usage',
'pending_messages.modified',
'session.mcp_server_status_changed',
'session.mcp_servers_loaded',
'session.skills_loaded',
'session.tools_updated',
]);
function shouldForwardMissionControlEvent(event: { type?: string; data?: unknown }): boolean {
const eventType = event.type ?? 'unknown';
if (skippedMissionControlEventTypes.has(eventType)) {
return false;
}
if (eventType === 'tool.execution_start' || eventType === 'tool.execution_complete') {
const toolName = typeof event.data === 'object' && event.data !== null && 'toolName' in event.data
? event.data.toolName
: undefined;
if (toolName === 'report_intent') {
return false;
}
}
return true;
}
function getMissionControlCommandIdFromEvent(event: { type?: string; data?: unknown }): string | undefined {
if (event.type !== 'user.message') {
return undefined;
}
const source = typeof event.data === 'object' && event.data !== null && 'source' in event.data
? event.data.source
: undefined;
return typeof source === 'string' && source.startsWith('command-')
? source.slice('command-'.length)
: undefined;
}
function getMissionControlModeCommand(content: string): MissionControlMode | undefined {
const trimmedContent = content.trim();
if (!trimmedContent.startsWith('{')) {
return undefined;
}
try {
const parsed = JSON.parse(trimmedContent) as McModeCommandData;
switch (parsed.mode) {
case 'plan':
case 'autopilot':
case 'interactive':
return parsed.mode;
case 'auto':
case 'autoApprove':
return 'autopilot';
}
} catch {
}
return undefined;
}
function isMissionControlCommandSource(source: SendOptions['source'] | undefined): boolean {
return typeof source === 'string' && source.startsWith('command-');
}
function getMissionControlSessionTitleFromEvent(event: { type?: string; data?: unknown }): string | undefined {
if (event.type !== 'session.title_changed') {
return undefined;
}
const title = typeof event.data === 'object' && event.data !== null && 'title' in event.data
? event.data.title
: undefined;
return typeof title === 'string' && title.trim().length > 0 ? title : undefined;
}
function getMissionControlEventData(event: { type?: string; data?: unknown }): Record<string, unknown> {
if (!event.data || typeof event.data !== 'object') {
return {};
}
const data = event.data as Record<string, unknown>;
if (event.type === 'user.message') {
const content = data.content;
if (typeof content !== 'string') {
return data;
}
const sanitizedContent = stripReminders(content);
return sanitizedContent === content ? data : { ...data, content: sanitizedContent };
}
if (event.type !== 'tool.execution_start') {
return data;
}
const toolName = data.toolName;
if (toolName !== 'bash' && toolName !== 'powershell' && toolName !== 'task') {
return data;
}
const args = data.arguments;
if (!args || typeof args !== 'object' || !('description' in args)) {
return data;
}
const { description: _description, ...sanitizedArgs } = args as Record<string, unknown>;
return { ...data, arguments: sanitizedArgs };
}
function getMissionControlPendingCommandCompletionIds(state: McSharedState): Set<string> {
state.mcPendingCommandCompletionIds ??= new Set();
return state.mcPendingCommandCompletionIds;
}
function getMissionControlPendingUserInputRequests(state: McSharedState): Set<McPendingUserInputRequest> {
state.mcPendingUserInputRequests ??= new Set();
return state.mcPendingUserInputRequests;
}
function getMissionControlPendingUserInputRequest(state: McSharedState, payload: McAskUserResponsePayload | undefined): McPendingUserInputRequest | undefined {
const pendingRequests = [...getMissionControlPendingUserInputRequests(state)];
const identifiers = [
payload?.requestId,
payload?.promptId,
payload?.toolCallId,
].filter((value): value is string => typeof value === 'string' && value.length > 0);
if (identifiers.length > 0) {
return pendingRequests.find(request =>
identifiers.includes(request.requestId) ||
(typeof request.toolCallId === 'string' && identifiers.includes(request.toolCallId))
);
}
return pendingRequests.length === 1 ? pendingRequests[0] : undefined;
}
function toSdkUserInputResponse(answer: IQuestionAnswer | undefined): UserInputResponse {
if (!answer) {
return { answer: '', wasFreeform: false };
}
if (answer.freeText) {
return { answer: answer.freeText, wasFreeform: true };
}
return { answer: answer.selected.join(', '), wasFreeform: false };
}
function getMcAskUserResponse(payload: McAskUserResponsePayload | undefined, rawContent: string): UserInputResponse | undefined {
const response = payload?.response ?? payload;
const answer = typeof response?.answer === 'string'
? response.answer
: typeof response?.freeText === 'string'
? response.freeText
: Array.isArray(response?.selected)
? response.selected.filter((value): value is string => typeof value === 'string').join(', ')
: response?.skipped
? ''
: payload === undefined
? rawContent
: undefined;
if (answer === undefined) {
return undefined;
}
return {
answer,
wasFreeform: typeof response?.wasFreeform === 'boolean'
? response.wasFreeform
: typeof response?.freeText === 'string',
};
}
function maybeAcknowledgeMissionControlCommandFromEvent(state: McSharedState, event: { type?: string; data?: unknown }): void {
const commandId = getMissionControlCommandIdFromEvent(event);
if (!commandId) {
return;
}
if (getMissionControlPendingCommandCompletionIds(state).delete(commandId)) {
state.mcCompletedCommandIds.push(commandId);
}
}
export { builtinSlashCommands as builtinSlashSCommands } from '../../common/builtinSlashCommands';
/**
* Either a free-form prompt **or** a known command.
*/
export type CopilotCLISessionInput =
| { readonly prompt: string; readonly source?: SendOptions['source'] }
| { readonly prompt?: string; readonly command: CopilotCLICommand; readonly source?: SendOptions['source'] };
function getPromptLabel(input: CopilotCLISessionInput): string {
if ('command' in input) {
const prompt = input.prompt ?? '';
return prompt ? `/${input.command} ${prompt}` : `/${input.command}`;
}
return input.prompt;
}
function getRemoteControlArgs(input: CopilotCLISessionInput): string {
const prompt = stripReminders(('prompt' in input ? input.prompt : '') ?? '').trim().toLowerCase();
if (prompt === '/remote' || prompt === 'remote') {
return '';
}
for (const commandPrefix of ['/remote ', 'remote ']) {
if (prompt.startsWith(commandPrefix)) {
return prompt.slice(commandPrefix.length).trim();
}
}
return prompt;
}
const enum QrMaskPattern {
Pattern0 = 0,
Pattern1 = 1,
Pattern2 = 2,
Pattern3 = 3,
Pattern4 = 4,
Pattern5 = 5,
Pattern6 = 6,
Pattern7 = 7,
}
const qrVersion = 6;
const qrSize = 17 + 4 * qrVersion;
const qrDataCodewords = 108;
const qrDataBlocks = 4;
const qrDataCodewordsPerBlock = 27;
const qrErrorCodewordsPerBlock = 16;
const qrQuietZoneModules = 4;
const qrSvgModuleSize = 5;
const qrGfExp = new Array<number>(512);
const qrGfLog = new Array<number>(256);
function initializeQrGaloisField(): void {
if (qrGfExp[0] !== undefined) {
return;
}
let value = 1;
for (let i = 0; i < 255; i++) {
qrGfExp[i] = value;
qrGfLog[value] = i;
value <<= 1;
if (value & 0x100) {
value ^= 0x11d;
}
}
for (let i = 255; i < qrGfExp.length; i++) {
qrGfExp[i] = qrGfExp[i - 255];
}
}
function qrGfMultiply(a: number, b: number): number {
if (!a || !b) {
return 0;
}
return qrGfExp[qrGfLog[a] + qrGfLog[b]];
}
function getQrGeneratorPolynomial(degree: number): number[] {
initializeQrGaloisField();
let polynomial = [1];
for (let i = 0; i < degree; i++) {
const next = new Array<number>(polynomial.length + 1).fill(0);
for (let j = 0; j < polynomial.length; j++) {
next[j] ^= polynomial[j];
next[j + 1] ^= qrGfMultiply(polynomial[j], qrGfExp[i]);
}
polynomial = next;
}
return polynomial.slice(1);
}
function getQrErrorCodewords(data: number[], degree: number): number[] {
const generator = getQrGeneratorPolynomial(degree);
const result = new Array<number>(degree).fill(0);
for (const codeword of data) {
const factor = codeword ^ result[0];
result.shift();
result.push(0);
for (let i = 0; i < degree; i++) {
result[i] ^= qrGfMultiply(generator[i], factor);
}
}
return result;
}
function appendQrBits(bits: boolean[], value: number, length: number): void {
for (let i = length - 1; i >= 0; i--) {
bits.push(((value >>> i) & 1) === 1);
}
}
function getQrDataCodewords(data: string): number[] {
const bytes = Array.from(Buffer.from(data, 'utf8'));
if (bytes.length > 106) {
throw new Error('Remote control URL is too long to render as a QR code.');
}
const bits: boolean[] = [];
appendQrBits(bits, 0b0100, 4);
appendQrBits(bits, bytes.length, 8);
for (const byte of bytes) {
appendQrBits(bits, byte, 8);
}
for (let i = 0; i < 4 && bits.length < qrDataCodewords * 8; i++) {
bits.push(false);
}
while (bits.length % 8 !== 0) {
bits.push(false);
}
const codewords: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let codeword = 0;
for (let j = 0; j < 8; j++) {
codeword = (codeword << 1) | (bits[i + j] ? 1 : 0);
}
codewords.push(codeword);
}
for (let pad = 0; codewords.length < qrDataCodewords; pad++) {
codewords.push(pad % 2 === 0 ? 0xec : 0x11);
}
return codewords;
}
function getQrCodewords(data: string): number[] {
const dataCodewords = getQrDataCodewords(data);
const blocks: { data: number[]; error: number[] }[] = [];
for (let i = 0; i < qrDataBlocks; i++) {
const blockData = dataCodewords.slice(i * qrDataCodewordsPerBlock, (i + 1) * qrDataCodewordsPerBlock);
blocks.push({ data: blockData, error: getQrErrorCodewords(blockData, qrErrorCodewordsPerBlock) });
}
const result: number[] = [];
for (let i = 0; i < qrDataCodewordsPerBlock; i++) {
for (const block of blocks) {
result.push(block.data[i]);
}
}
for (let i = 0; i < qrErrorCodewordsPerBlock; i++) {
for (const block of blocks) {
result.push(block.error[i]);
}
}
return result;
}
function createQrMatrix(): { modules: boolean[][]; reserved: boolean[][] } {
const modules = Array.from({ length: qrSize }, () => new Array<boolean>(qrSize).fill(false));
const reserved = Array.from({ length: qrSize }, () => new Array<boolean>(qrSize).fill(false));
const setModule = (x: number, y: number, dark: boolean) => {
if (x < 0 || y < 0 || x >= qrSize || y >= qrSize) {
return;
}
modules[y][x] = dark;
reserved[y][x] = true;
};
const addFinder = (x: number, y: number) => {
for (let dy = -1; dy <= 7; dy++) {
for (let dx = -1; dx <= 7; dx++) {
const isPattern = dx >= 0 && dx <= 6 && dy >= 0 && dy <= 6
&& (dx === 0 || dx === 6 || dy === 0 || dy === 6 || (dx >= 2 && dx <= 4 && dy >= 2 && dy <= 4));
setModule(x + dx, y + dy, isPattern);
}
}
};
addFinder(0, 0);
addFinder(qrSize - 7, 0);
addFinder(0, qrSize - 7);
for (let i = 8; i < qrSize - 8; i++) {
setModule(i, 6, i % 2 === 0);
setModule(6, i, i % 2 === 0);
}
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
setModule(34 + dx, 34 + dy, Math.max(Math.abs(dx), Math.abs(dy)) === 2 || (dx === 0 && dy === 0));
}
}
setModule(8, 4 * qrVersion + 9, true);
for (let i = 0; i <= 8; i++) {
if (i !== 6) {
setModule(8, i, false);
setModule(i, 8, false);
}
}
for (let i = 0; i < 8; i++) {
setModule(qrSize - 1 - i, 8, false);
}
for (let i = 0; i < 7; i++) {
setModule(8, qrSize - 1 - i, false);
}
return { modules, reserved };
}
function getQrMask(mask: QrMaskPattern, x: number, y: number): boolean {
switch (mask) {
case QrMaskPattern.Pattern0: return (x + y) % 2 === 0;
case QrMaskPattern.Pattern1: return y % 2 === 0;
case QrMaskPattern.Pattern2: return x % 3 === 0;
case QrMaskPattern.Pattern3: return (x + y) % 3 === 0;
case QrMaskPattern.Pattern4: return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0;
case QrMaskPattern.Pattern5: return ((x * y) % 2) + ((x * y) % 3) === 0;
case QrMaskPattern.Pattern6: return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
case QrMaskPattern.Pattern7: return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
}
}
function setQrFormatInfo(modules: boolean[][], mask: QrMaskPattern): void {
let format = mask;
let remainder = format << 10;
for (let i = 14; i >= 10; i--) {
if (((remainder >>> i) & 1) !== 0) {
remainder ^= 0x537 << (i - 10);
}
}
format = ((format << 10) | remainder) ^ 0x5412;
const setBit = (x: number, y: number, bitIndex: number) => {
modules[y][x] = ((format >>> bitIndex) & 1) !== 0;
};
for (let i = 0; i <= 5; i++) {
setBit(8, i, i);
}
setBit(8, 7, 6);
setBit(8, 8, 7);
setBit(7, 8, 8);
for (let i = 9; i < 15; i++) {
setBit(14 - i, 8, i);
}
for (let i = 0; i < 8; i++) {
setBit(qrSize - 1 - i, 8, i);
}
for (let i = 8; i < 15; i++) {
setBit(8, qrSize - 15 + i, i);
}
}
function getQrPenalty(modules: boolean[][]): number {
let penalty = 0;
const scoreRuns = (line: boolean[]) => {
let runColor = line[0];
let runLength = 1;
for (let i = 1; i <= line.length; i++) {
if (i < line.length && line[i] === runColor) {
runLength++;
} else {
if (runLength >= 5) {
penalty += 3 + runLength - 5;
}
runColor = line[i];
runLength = 1;
}
}
};
for (let y = 0; y < qrSize; y++) {
scoreRuns(modules[y]);
}
for (let x = 0; x < qrSize; x++) {
scoreRuns(modules.map(row => row[x]));
}
for (let y = 0; y < qrSize - 1; y++) {
for (let x = 0; x < qrSize - 1; x++) {
const color = modules[y][x];
if (modules[y][x + 1] === color && modules[y + 1][x] === color && modules[y + 1][x + 1] === color) {
penalty += 3;
}
}
}
const finderPattern = '10111010000';
const reverseFinderPattern = '00001011101';
const scoreFinderPattern = (line: boolean[]) => {
const text = line.map(bit => bit ? '1' : '0').join('');
for (let i = 0; i <= text.length - finderPattern.length; i++) {
const slice = text.slice(i, i + finderPattern.length);
if (slice === finderPattern || slice === reverseFinderPattern) {
penalty += 40;
}
}
};
for (let y = 0; y < qrSize; y++) {
scoreFinderPattern(modules[y]);
}
for (let x = 0; x < qrSize; x++) {
scoreFinderPattern(modules.map(row => row[x]));
}
const darkModules = modules.flat().filter(Boolean).length;
const darkPercent = darkModules * 100 / (qrSize * qrSize);
penalty += Math.floor(Math.abs(darkPercent - 50) / 5) * 10;
return penalty;
}
function buildQrMatrix(data: string): boolean[][] {
const codewordBits = getQrCodewords(data).flatMap(codeword => {
const bits: boolean[] = [];
appendQrBits(bits, codeword, 8);
return bits;
});
let bestModules: boolean[][] | undefined;
let bestPenalty = Number.MAX_SAFE_INTEGER;
for (let mask = 0; mask <= 7; mask++) {
const { modules, reserved } = createQrMatrix();
let bitIndex = 0;
let upward = true;
for (let right = qrSize - 1; right >= 1; right -= 2) {
if (right === 6) {
right--;
}
for (let vertical = 0; vertical < qrSize; vertical++) {
const y = upward ? qrSize - 1 - vertical : vertical;
for (let column = 0; column < 2; column++) {
const x = right - column;
if (reserved[y][x]) {
continue;
}
const bit = bitIndex < codewordBits.length ? codewordBits[bitIndex++] : false;
modules[y][x] = bit !== getQrMask(mask, x, y);
}
}
upward = !upward;
}
setQrFormatInfo(modules, mask);
const penalty = getQrPenalty(modules);
if (penalty < bestPenalty) {
bestPenalty = penalty;
bestModules = modules;
}
}
if (!bestModules) {
throw new Error('Unable to render QR code.');
}
return bestModules;
}
async function renderRemoteControlQrCode(data: string): Promise<string> {
const modules = buildQrMatrix(data);
const imageSize = (qrSize + qrQuietZoneModules * 2) * qrSvgModuleSize;
const path = modules.flatMap((row, y) => row.map((dark, x) => {
if (!dark) {
return '';
}
const moduleX = (x + qrQuietZoneModules) * qrSvgModuleSize;
const moduleY = (y + qrQuietZoneModules) * qrSvgModuleSize;
return `M${moduleX} ${moduleY}h${qrSvgModuleSize}v${qrSvgModuleSize}h-${qrSvgModuleSize}z`;
})).join('');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${imageSize} ${imageSize}" width="${imageSize}" height="${imageSize}"><path fill="#fff" d="M0 0h${imageSize}v${imageSize}H0z"/><path fill="#000" d="${path}"/></svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}
export interface ICopilotCLISession extends IDisposable {
readonly sessionId: string;
readonly title?: string;
readonly createdPullRequestUrl: string | undefined;
readonly onDidChangeTitle: vscode.Event<string>;
readonly status: vscode.ChatSessionStatus | undefined;
readonly onDidChangeStatus: vscode.Event<vscode.ChatSessionStatus | undefined>;
readonly workspace: IWorkspaceInfo;
readonly additionalWorkspaces: IWorkspaceInfo[];
readonly pendingPrompt: string | undefined;
attachStream(stream: vscode.ChatResponseStream): IDisposable;
setPermissionLevel(level: string | undefined): void;
handleRequest(
request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },
input: CopilotCLISessionInput,
attachments: Attachment[],
model: { model: string; reasoningEffort?: string; contextTier?: 'default' | 'long_context' } | undefined,
authInfo: NonNullable<SessionOptions['authInfo']>,
token: vscode.CancellationToken
): Promise<void>;
addUserMessage(content: string): void;
addUserAssistantMessage(content: string): void;
getSelectedModelId(): Promise<string | undefined>;
getLastResponseModelId(): string | undefined;
}
export class CopilotCLISession extends DisposableStore implements ICopilotCLISession {
public readonly sessionId: string;
private _createdPullRequestUrl: string | undefined;
public get createdPullRequestUrl(): string | undefined {
return this._createdPullRequestUrl;
}
private _status?: vscode.ChatSessionStatus;
public get status(): vscode.ChatSessionStatus | undefined {
return this._status;
}
private readonly _statusChange = this.add(new EventEmitter<vscode.ChatSessionStatus | undefined>());
public readonly onDidChangeStatus = this._statusChange.event;
private _title?: string;
public get title(): string | undefined {
return this._title;
}
private _onDidChangeTitle = this.add(new Emitter<string>());
public onDidChangeTitle = this._onDidChangeTitle.event;
private readonly _streamRouter: CopilotCLIResponseStreamRouter;
private readonly _stream: vscode.ChatResponseStream;
private _toolInvocationToken?: ChatParticipantToolToken;
public get sdkSession() {
return this._sdkSession;
}
public get workspace() {
return this._workspaceInfo;
}
public get additionalWorkspaces() {
return this._additionalWorkspaces;
}
private _lastUsedModel: string | undefined;
private _permissionLevel: string | undefined;
private _lastResponseModelId: string | undefined;
private _pendingPrompt: string | undefined;
private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined;
private readonly _todoSqlQuery = new TodoSqlQuery();
private readonly _missionControlApiClient: MissionControlApiClient;
private _cancelPendingCancellationAbort: (() => void) | undefined;
/** Get or create shared MC state for this SDK session. */
private get _mcState(): McSharedState | undefined {
return mcStateBySessionId.get(this.sessionId);
}
/** Callback to propagate trace context to the SDK's OtelLifecycle. */
private _updateSdkTraceContext: ((traceparent?: string, tracestate?: string) => void) | undefined;
public get pendingPrompt(): string | undefined {
return this._pendingPrompt;
}
/** Set the bridge processor for forwarding SDK spans to the debug panel. */
setBridgeProcessor(bridge: CopilotCliBridgeSpanProcessor | undefined): void {
this._bridgeProcessor = bridge;
}
/** Set the SDK OTel trace context updater (pre-bound with sessionId). */
setSdkTraceContextUpdater(updater: ((traceparent?: string, tracestate?: string) => void) | undefined): void {
this._updateSdkTraceContext = updater;
}
constructor(
private readonly _workspaceInfo: IWorkspaceInfo,
private readonly _agentName: string | undefined,
private readonly _sdkSession: Session,
private readonly _additionalWorkspaces: IWorkspaceInfo[],
private readonly _sandboxConfig: SessionOptions['sandboxConfig'],
@ILogService private readonly logService: ILogService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IChatSessionMetadataStore private readonly _chatSessionMetadataStore: IChatSessionMetadataStore,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IRequestLogger private readonly _requestLogger: IRequestLogger,
@ICopilotCLIImageSupport private readonly _imageSupport: ICopilotCLIImageSupport,
@IToolsService private readonly _toolsService: IToolsService,
@IUserQuestionHandler private readonly _userQuestionHandler: IUserQuestionHandler,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IOTelService private readonly _otelService: IOTelService,
@IGitService private readonly _gitService: IGitService,
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
@IChatQuotaService private readonly _chatQuotaService: IChatQuotaService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
) {
super();
this.sessionId = _sdkSession.sessionId;
this._streamRouter = new CopilotCLIResponseStreamRouter(this.logService, this.sessionId);
this._stream = this._streamRouter.stream;
this._missionControlApiClient = this.instantiationService.createInstance(MissionControlApiClient);
this.add(toDisposable(() => this._todoSqlQuery.dispose()));
}
attachStream(stream: vscode.ChatResponseStream): IDisposable {
return this._streamRouter.attach(stream);
}
public setPermissionLevel(level: string | undefined): void {
this._permissionLevel = level;
}
/**
* Whether the session was configured with the sandbox enabled. The sandbox
* only actually applies to requests that run with default approvals — see
* {@link _applyEffectiveSandboxConfig}.
*/
private get _sandboxEnabled(): boolean {
return !!this._sandboxConfig?.enabled;
}
/**
* Apply the sandbox policy for the request that is about to be sent. The
* sandbox enable setting only applies under default approvals; the sandbox
* is explicitly disabled when the request runs with bypass approvals
* (autopilot / autoApprove) or when no sandbox is configured for the
* session. Pushing `{ enabled: false }` (rather than skipping the update)
* ensures the SDK never retains a stale or auto-discovered sandbox.
*/
private _applyEffectiveSandboxConfig(bypassApprovals: boolean): void {
const base = this._sandboxConfig;
const sandboxConfig = (base?.enabled && !bypassApprovals) ? base : { enabled: false };
try {
this._sdkSession.updateOptions({ sandboxConfig });
} catch (error) {
this.logService.error(error, '[CopilotCLISession] Failed to update sandbox config for request');
}
}
// TODO: This should be pre-populated when we restore a session based on its original context.
// E.g. if we're resuming a session, and it tries to read a file, we shouldn't prompt for permissions again.
/**
* Accumulated attachments across all requests in this session.
* Used for permission auto-approval: if a file was attached by the user in any
* request, read access is auto-approved for that file in subsequent turns.
*/
private readonly attachments: Attachment[] = [];
/**
* Promise chain that serialises request completion tracking.
* When a steering request arrives while a previous request is still running,
* the steering handler awaits both `previousRequest` and its own SDK send so
* that the steering message does not resolve until the original request finishes.
*/
private previousRequest: Promise<unknown> = Promise.resolve();
/**
* Entry point for every chat request against this session.
*
* **Steering behaviour**: if the session is already busy (`InProgress` or
* `NeedsInput`), the incoming message is treated as a *steering* request.
* Steering sends the new prompt to the SDK with `mode: 'immediate'` so it is
* injected into the running conversation as additional context. The steering
* request only resolves once *both* the steering send and the original
* in-flight request have completed, keeping the session's promise chain
* consistent.
*
* When the session is idle, a normal full request is started instead.
*/
public async handleRequest(
request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },
input: CopilotCLISessionInput,
attachments: Attachment[],
model: { model: string; reasoningEffort?: string; contextTier?: 'default' | 'long_context' } | undefined,
authInfo: NonNullable<SessionOptions['authInfo']>,
token: vscode.CancellationToken
): Promise<void> {
if (this.isDisposed) {
throw new Error('Session disposed');
}
const label = getPromptLabel(input);
const promptLabel = truncate(label, 50);
const capturingToken = new CapturingToken(`Copilot CLI | ${promptLabel}`, 'worktree', undefined, undefined, this.sessionId);
const isAlreadyBusyWithAnotherRequest = !!this._status && (this._status === ChatSessionStatus.InProgress || this._status === ChatSessionStatus.NeedsInput);
this._toolInvocationToken = request.toolInvocationToken;
const previousRequestSnapshot = this.previousRequest;
const handled = this._requestLogger.captureInvocation(capturingToken, async () => {
await this.updateModel(model?.model, model?.reasoningEffort, model?.contextTier, authInfo, token);
if (isAlreadyBusyWithAnotherRequest) {
return this._handleRequestSteering(input, attachments, model, previousRequestSnapshot, token);
} else {
return this._handleRequestImpl(request, input, attachments, model, token);
}
});
this.previousRequest = this.previousRequest.then(() => handled).catch(() => { /* prevent unhandled rejection on the serialisation chain */ });
return handled;
}
/**
* Handles a steering request - a message sent while the session is already
* busy with a previous request.
*
* The steering prompt is sent to the SDK with `mode: 'immediate'` (via
* {@link sendRequestInternal}) so the SDK injects it into the running
* conversation as additional user context. The SDK send itself typically
* completes quickly (it only enqueues the message), but we also await
* `previousRequestPromise` so that this method does not resolve until the
* original in-flight request is fully done. This ensures callers see the
* correct session state when the returned promise settles.
*
* @param previousRequestPromise A snapshot of `this.previousRequest` captured
* *before* the promise chain was extended with the current call. Using the
* snapshot avoids a circular await that would deadlock.
*/
private async _handleRequestSteering(
input: CopilotCLISessionInput,
attachments: Attachment[],
model: { model: string; reasoningEffort?: string; contextTier?: 'default' | 'long_context' } | undefined,
previousRequestPromise: Promise<unknown>,
token: vscode.CancellationToken,
): Promise<void> {
this.attachments.push(...attachments);
const prompt = getPromptLabel(input);
this._pendingPrompt = prompt;
const disposables = new DisposableStore();
const logStartTime = Date.now();
disposables.add(token.onCancellationRequested(() => {
this._cancelPendingCancellationAbort?.();
this._sdkSession.abort();
}));
disposables.add(toDisposable(() => this._sdkSession.abort()));
try {
if ('command' in input && input.command !== 'plan') {
this._cancelPendingCancellationAbort?.();
await previousRequestPromise;
if (!token.isCancellationRequested) {
this._stream?.markdown('\n\n');
await this.sendRequestInternal(input, attachments, false, logStartTime);
}
} else {
// Send the steering prompt (completes quickly) and also wait for the
// previous request to finish, so this promise settles only once all
// in-flight work is done.
await Promise.all([previousRequestPromise, this.sendRequestInternal(input, attachments, true, logStartTime)]);
}
this._logConversation(prompt, '', model?.model || '', attachments, logStartTime, 'Completed');
} catch (error) {
this._logConversation(prompt, '', model?.model || '', attachments, logStartTime, 'Failed', error instanceof Error ? error.message : String(error));
throw error;
} finally {
disposables.dispose();
}
}
private async _handleRequestImpl(
request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },
input: CopilotCLISessionInput,
attachments: Attachment[],
model: { model: string; reasoningEffort?: string; contextTier?: 'default' | 'long_context' } | undefined,
token: vscode.CancellationToken
): Promise<void> {
const modelId = model?.model;
const promptLabel = getPromptLabel(input);
return this._otelService.startActiveSpan(
'invoke_agent copilotcli',
{
kind: SpanKind.INTERNAL,
attributes: {
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,
[GenAiAttr.AGENT_NAME]: 'copilotcli',
[GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB,
[GenAiAttr.CONVERSATION_ID]: this.sessionId,
[CopilotChatAttr.SESSION_ID]: this.sessionId,
[CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId,
...(modelId ? { [GenAiAttr.REQUEST_MODEL]: modelId } : {}),
[CopilotChatAttr.USER_REQUEST]: truncateForOTel(promptLabel, this._otelService.config.maxAttributeSizeChars),
...workspaceMetadataToOTelAttributes(resolveWorkspaceOTelMetadata(this._gitService)),
},
},
async span => {
// Emit user_message event so chronicle can extract turns and summary
span.addEvent('user_message', { content: truncateForOTel(promptLabel, this._otelService.config.maxAttributeSizeChars) });
// Register the trace context so the bridge processor can inject CHAT_SESSION_ID
const traceCtx = span.getSpanContext();
if (traceCtx && this._bridgeProcessor) {
this._bridgeProcessor.registerTrace(traceCtx.traceId, this.sessionId);
}
// Propagate trace context to SDK so its spans are children of this span
if (traceCtx && this._updateSdkTraceContext) {
const traceparent = `00-${traceCtx.traceId}-${traceCtx.spanId}-01`;
this._updateSdkTraceContext(traceparent);
}
try {
return await this._handleRequestImplInner(span, request, input, attachments, modelId, token);
} finally {
if (traceCtx && this._bridgeProcessor) {
this._bridgeProcessor.unregisterTrace(traceCtx.traceId);
}
// Clear SDK trace context so it doesn't leak to next request
if (this._updateSdkTraceContext) {
this._updateSdkTraceContext(undefined);
}
}
},
);
}
private async _handleRequestImplInner(
invokeAgentSpan: ISpanHandle,
request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },
input: CopilotCLISessionInput,
attachments: Attachment[],
modelId: string | undefined,
token: vscode.CancellationToken
): Promise<void> {
this.attachments.push(...attachments);
const prompt = getPromptLabel(input);
this._pendingPrompt = prompt;
this._lastResponseModelId = undefined;
this._chatQuotaService.resetTurnCredits(request.id);
this.logService.info(`[CopilotCLISession] Invoking session ${this.sessionId}`);
const disposables = new DisposableStore();
const logStartTime = Date.now();
const requestStream = this._stream;
let wroteResponseContent = false;
let cancelCancellationAbort: (() => void) | undefined;
disposables.add(token.onCancellationRequested(() => {
const cancelAbort = () => {
clearTimeout(abortHandle);
if (this._cancelPendingCancellationAbort === cancelAbort) {
this._cancelPendingCancellationAbort = undefined;
}
};
const abortHandle = setTimeout(() => {
if (this._cancelPendingCancellationAbort === cancelAbort) {
this._cancelPendingCancellationAbort = undefined;
}
if (!wroteResponseContent) {
try {
requestStream?.markdown(l10n.t('Response was interrupted.'));
wroteResponseContent = true;
} catch (error) {
this.logService.trace(`[CopilotCLISession] Unable to mark interrupted response: ${error instanceof Error ? error.message : String(error)}`);
}
}
this._sdkSession.abort();
}, 250);
this._cancelPendingCancellationAbort?.();
this._cancelPendingCancellationAbort = cancelAbort;
cancelCancellationAbort = cancelAbort;
}));
disposables.add(toDisposable(() => this._sdkSession.abort()));
this._status = ChatSessionStatus.InProgress;
this._statusChange.fire(this._status);
const pendingToolInvocations = new Map<string, [ChatToolInvocationPart | ChatResponseMarkdownPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
const editToolIds = new Set<string>();
const toolCalls = new Map<string, ToolCall>();
const toolStartTimes = new Map<string, number>();
// Synthesized `execute_tool` spans for native CLI tools (those that execute inside the SDK
// and therefore never reach the tools service). MCP/VS Code tools already emit `execute_tool`
// spans via the tools service, so we skip those here to avoid duplicate debug-log entries.
// Synthesizing these spans is what surfaces native tool calls (e.g. powershell, grep) in the
// chat debug logs view for the in-process Copilot CLI experience.
const syntheticToolSpans = new Map<string, ISpanHandle>();
// Per-model-turn usage reported by the SDK (`assistant.usage`). Used at request completion to
// synthesize one `chat` span per turn so the chat debug logs view shows the model turns, token
// metrics, and the agent response for the in-process Copilot CLI experience (the SDK performs
// the model call natively and never produces a JS span we could observe directly).
const modelTurnUsages: IModelTurnUsage[] = [];
const invokeAgentTraceContext = invokeAgentSpan.getSpanContext();
const editTracker = new ExternalEditTracker();
let sdkRequestId: string | undefined;
let isQuotaError = false;
const toolIdEditMap = new Map<string, Promise<string | undefined>>();
const remoteMode = isMissionControlCommandSource(input.source) ? this._mcState?.mcMode : undefined;
const effectivePermissionLevel = remoteMode ? (remoteMode === 'autopilot' ? 'autopilot' : undefined) : this._permissionLevel;
clearTodoList(this._toolsService, request.toolInvocationToken, token).catch(err => {
this.logService.error(err, '[CopilotCLISession] Failed to clear todo list at start of session');
});
/**
* The sequence of events from the SDK is as follows:
* tool.start -> About to run a terminal command
* permission request -> Asks user for permission to run the command
* tool.complete -> Command has completed running, contains the output or error
*
* There's a problem with this flow, we end up displaying the UI about execution in progress, even before we asked for permissions.
* This looks weird because we display two UI elements in sequence, one for "Running command..." and then immediately after "Permission requested: Allow running this command?".
* To fix this, we delay showing the "Running command..." UI until after the permission request is resolved. If the permission request is approved, we then show the "Running command..." UI. If the permission request is denied, we show a message indicating that the command was not run due to lack of permissions.
* & if we don't get a permission request, but get some other event, then we show the "Running command..." UI immediately as before.
*/
const toolCallWaitingForPermissions: [ChatToolInvocationPart, ToolCall][] = [];
const flushPendingInvocationMessages = () => {
for (const [invocationMessage,] of toolCallWaitingForPermissions) {
requestStream?.push(invocationMessage);
}
toolCallWaitingForPermissions.length = 0;
};
// Flush only the tool invocation matching the given toolCallId, leaving other
// pending tools in the array. This prevents parallel tool calls from being
// prematurely pushed to the stream when only one of them has been approved.
const flushPendingInvocationMessageForToolCallId = (toolCallId: string | undefined) => {
if (!toolCallId) {
flushPendingInvocationMessages();
return;
}
const index = toolCallWaitingForPermissions.findIndex(([, tc]) => tc.toolCallId === toolCallId);
if (index !== -1) {
const [[invocationMessage]] = toolCallWaitingForPermissions.splice(index, 1);
requestStream?.push(invocationMessage);
}
};
const chunkMessageIds = new Set<string>();
const assistantMessageChunks: string[] = [];
// Tracks the `messageId` of the last assistant text we forwarded to
// the stream (via `assistant.message_delta` or `assistant.message`).
// When the next text emission carries a different `messageId` — i.e.
// the model emitted a new assistant message in the same turn (e.g.
// after a tool call, or as a second phase) — we prepend `\n\n` so the
// two messages don't fuse into a single run-on paragraph
// (e.g. `"...wiring:Now add..."`). Only triggers when both sides have
// a defined messageId, so message emissions without an id (rare /
// legacy) keep their current behavior.
let lastEmittedAssistantMessageId: string | undefined;
const maybeEmitMessageSeparator = (incomingMessageId: string | undefined) => {
if (
incomingMessageId !== undefined &&
lastEmittedAssistantMessageId !== undefined &&
incomingMessageId !== lastEmittedAssistantMessageId
) {
requestStream?.markdown('\n\n');
}
if (incomingMessageId !== undefined) {
lastEmittedAssistantMessageId = incomingMessageId;
}
};
let lastUsageInfo: UsageInfoData | undefined;
const reportUsage = (promptTokens: number, completionTokens: number) => {
if (token.isCancellationRequested || !requestStream) {
return;
}
requestStream.usage({
promptTokens,
completionTokens,
promptTokenDetails: buildPromptTokenDetails(lastUsageInfo),
});
};
const updateUsageInfo = (async () => {
const metrics = await this._sdkSession.usage.getMetrics();
const promptTokens = lastUsageInfo?.currentTokens || metrics.lastCallInputTokens;
reportUsage(promptTokens, metrics.lastCallOutputTokens);
})();
try {
const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled);
disposables.add(toDisposable(this._sdkSession.on('*', (event) => {
// Forward events to Mission Control if remote control is active
this._bufferMcEvent(event);
this._logSessionEvent(event);
})));
disposables.add(toDisposable(this._sdkSession.on('permission.requested', async (event) => {
const permissionRequest = event.data.permissionRequest;
const requestId = event.data.requestId;
const isSandboxBypassShell = permissionRequest.kind === 'shell' && permissionRequest.requestSandboxBypass === true;
// Auto-approve all requests when the permission level allows it.
if (!isSandboxBypassShell && (effectivePermissionLevel === 'autoApprove' || effectivePermissionLevel === 'autopilot')) {
this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${effectivePermissionLevel})`);
this._sdkSession.respondToPermission(requestId, { kind: 'approve-once' });
return;
}
if (!isSandboxBypassShell && permissionRequest.kind === 'shell' && this._sandboxEnabled) {
this.logService.trace(`[CopilotCLISession] Auto Approving shell request (sandbox is enabled)`);
this._sdkSession.respondToPermission(requestId, { kind: 'approve-once' });
return;
}
// Resolve tool call data for the permission request.
const toolData = permissionRequest.toolCallId ? toolCalls.get(permissionRequest.toolCallId) : undefined;
const pendingData = permissionRequest.toolCallId ? pendingToolInvocations.get(permissionRequest.toolCallId) : undefined;
const toolParentCallId = pendingData ? pendingData[2] : undefined;
const toolInvocationToken = this._toolInvocationToken as unknown as never;
const resolveLocalPermissionResponse = (permissionToken: CancellationToken): Promise<PermissionRequestResult> => {
switch (permissionRequest.kind) {
case 'read':
return handleReadPermission(
this.sessionId, permissionRequest, toolParentCallId,
this.attachments, this._imageSupport, this.workspace, this.workspaceService,
this._toolsService, toolInvocationToken, this.logService, permissionToken,
);
case 'write':
return handleWritePermission(
this.sessionId, permissionRequest, toolData, toolParentCallId,
requestStream, editTracker, this.workspace, this.workspaceService,
this.instantiationService, this._toolsService, toolInvocationToken, this.logService, permissionToken,
);
case 'shell':
return handleShellPermission(
permissionRequest, toolParentCallId,
this.workspace, this._toolsService, toolInvocationToken, this.logService, permissionToken,
);
case 'mcp':
return handleMcpPermission(
permissionRequest, toolParentCallId,
this._toolsService, toolInvocationToken, this.logService, permissionToken,
);
default:
return showInteractivePermissionPrompt(
permissionRequest, toolParentCallId,
this._toolsService, toolInvocationToken, this.logService, permissionToken,
);
}
};
try {
let response: PermissionRequestResult;
if (!isSandboxBypassShell && (effectivePermissionLevel === 'autoApprove' || effectivePermissionLevel === 'autopilot')) {
this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${effectivePermissionLevel})`);
response = { kind: 'approve-once' };
} else if (this._mcState) {
const permissionResolutionTokenSource = new CancellationTokenSource(token);
try {
response = await Promise.race([
resolveLocalPermissionResponse(permissionResolutionTokenSource.token),
this._waitForMcPermissionResponse(this._mcState, permissionRequest, requestId, permissionResolutionTokenSource.token),
]);
} finally {
permissionResolutionTokenSource.dispose(true);
}
} else {
response = await resolveLocalPermissionResponse(token);
}
flushPendingInvocationMessageForToolCallId(permissionRequest.toolCallId);
this._requestLogger.addEntry({
type: LoggedRequestKind.MarkdownContentRequest,
debugName: `Permission Request`,
startTimeMs: Date.now(),
icon: Codicon.question,
markdownContent: this._renderPermissionToMarkdown(permissionRequest, response.kind),
isConversationRequest: true
});
this._sdkSession.respondToPermission(requestId, response);
}
catch (error) {
this.logService.error(error, `[CopilotCLISession] Error handling permission request of kind ${permissionRequest.kind}`);
flushPendingInvocationMessageForToolCallId(permissionRequest.toolCallId);
this._sdkSession.respondToPermission(requestId, { kind: 'denied-interactively-by-user' });
}
})));
if (shouldHandleExitPlanModeRequests) {
disposables.add(toDisposable(this._sdkSession.on('exit_plan_mode.requested', async (event) => {
this.updateArtifacts();
try {
const response = await handleExitPlanMode(
event.data,
this._sdkSession,
effectivePermissionLevel,
this._toolInvocationToken,
this.workspaceService,
this.logService,
this._toolsService,
token,
);
flushPendingInvocationMessages();
this._sdkSession.respondToExitPlanMode(event.data.requestId, response);
} catch (error) {
this.logService.error(error, '[CopilotCLISession] Error handling exit plan mode');
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false });
}
})));
}
disposables.add(toDisposable(this._sdkSession.on('user_input.requested', async (event) => {
if (!(this._toolInvocationToken as unknown)) {
this.logService.warn('[AskQuestionsTool] No tool invocation token available, cannot show question carousel');
this._sdkSession.respondToUserInput(event.data.requestId, { answer: '', wasFreeform: false });
return;
}
const userInputRequest: IQuestion = {
question: event.data.question,
options: (event.data.choices ?? []).map(c => ({ label: c })),
allowFreeformInput: event.data.allowFreeform,
header: event.data.question,
};
let response: UserInputResponse;
if (this._mcState) {
const userInputResolutionTokenSource = new CancellationTokenSource(token);
const localQuestionPromise = this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, userInputResolutionTokenSource.token, event.data.toolCallId);
const remoteQuestionPromise = this._waitForMcUserInputResponse(this._mcState, event.data.requestId, event.data.toolCallId, userInputResolutionTokenSource.token);
try {
const result = await Promise.race([
localQuestionPromise.then(answer => ({ source: 'local' as const, response: toSdkUserInputResponse(answer) })),
remoteQuestionPromise.then(result => ({ source: 'remote' as const, response: result })),
]);
if (result.source === 'remote' && result.response && event.data.toolCallId) {
await this._userQuestionHandler.notifyQuestionCarouselAnswer?.(event.data.toolCallId, userInputRequest, result.response);
}
response = result.response ?? { answer: '', wasFreeform: false };
} finally {
userInputResolutionTokenSource.dispose(true);
}
} else {
response = toSdkUserInputResponse(await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token, event.data.toolCallId));
}
flushPendingInvocationMessages();
this._sdkSession.respondToUserInput(event.data.requestId, response);
})));
disposables.add(toDisposable(this._sdkSession.on('session.title_changed', (event) => {
this._title = event.data.title;
this._onDidChangeTitle.fire(event.data.title);
})));
disposables.add(toDisposable(this._sdkSession.on('user.message', (event) => {
sdkRequestId = sdkRequestId ?? event.id;
})));
disposables.add(toDisposable(this._sdkSession.on('assistant.usage', (event) => {
this._lastResponseModelId = event.data.model;
if (requestStream && typeof event.data.outputTokens === 'number' && typeof event.data.inputTokens === 'number') {
reportUsage(event.data.inputTokens, event.data.outputTokens);
}
// Accumulate per-turn credits from SDK copilotUsage data
const copilotUsage = (event.data as unknown as Record<string, unknown>).copilotUsage;
let copilotUsageNanoAiu: number | undefined;
if (copilotUsage && typeof copilotUsage === 'object') {
const { totalNanoAiu } = copilotUsage as { totalNanoAiu?: number };
if (typeof totalNanoAiu === 'number') {
copilotUsageNanoAiu = totalNanoAiu;
this._chatQuotaService.setLastCopilotUsage(totalNanoAiu, request.id);
}
}
// Sync the live per-category quota state the SDK reports (internal-only field) so the
// quota UI stays current without a separate `copilot_internal/user` fetch. This mirrors
// the extension-host chat path, which processes `copilot_quota_snapshots` from CAPI.
if (event.data.quotaSnapshots) {
this._chatQuotaService.processQuotaSnapshots(toChatQuotaSnapshots(event.data.quotaSnapshots));
}
// Record this model turn so we can synthesize a `chat` span for it at request completion.
modelTurnUsages.push({
model: event.data.model,
inputTokens: event.data.inputTokens,
outputTokens: event.data.outputTokens,
cacheReadTokens: event.data.cacheReadTokens,
copilotUsageNanoAiu,
parentToolCallId: event.data.parentToolCallId,
});
})));
disposables.add(toDisposable(this._sdkSession.on('session.usage_info', (event) => {
lastUsageInfo = {
currentTokens: event.data.currentTokens,
systemTokens: event.data.systemTokens,
conversationTokens: event.data.conversationTokens,
toolDefinitionsTokens: event.data.toolDefinitionsTokens,
tokenLimit: event.data.tokenLimit,
};
reportUsage(lastUsageInfo.currentTokens, 0);
})));
disposables.add(toDisposable(this._sdkSession.on('assistant.message_delta', (event) => {
// Support for streaming delta messages.
if (typeof event.data.deltaContent === 'string' && event.data.deltaContent.length) {
// Ensure pending invocation messages are flushed even if we skip sub-agent markdown
flushPendingInvocationMessages();
// Skip sub-agent markdown — it will be captured in the subagent tool's result
if (event.data.parentToolCallId) {
return;
}
maybeEmitMessageSeparator(event.data.messageId);
chunkMessageIds.add(event.data.messageId);
assistantMessageChunks.push(event.data.deltaContent);
wroteResponseContent = true;
requestStream?.markdown(event.data.deltaContent);
}
})));
disposables.add(toDisposable(this._sdkSession.on('assistant.message', (event) => {
if (typeof event.data.content === 'string' && event.data.content.length && !chunkMessageIds.has(event.data.messageId)) {
// Skip sub-agent markdown — it will be captured in the subagent tool's result
if (event.data.parentToolCallId) {
return;
}
assistantMessageChunks.push(event.data.content);
flushPendingInvocationMessages();
maybeEmitMessageSeparator(event.data.messageId);
wroteResponseContent = true;
requestStream?.markdown(event.data.content);
}
})));
disposables.add(toDisposable(this._sdkSession.on('tool.execution_start', (event) => {
toolCalls.set(event.data.toolCallId, event.data as unknown as ToolCall);
toolStartTimes.set(event.data.toolCallId, Date.now());
// Only synthesize tool spans when the bridge is absent. If a future SDK registers its own
// JS OTel provider the bridge forwards native tool spans, and synthesizing would duplicate them.
if (!this._bridgeProcessor) {
this._startSyntheticToolSpan(event, syntheticToolSpans, invokeAgentTraceContext);
}
if (isCopilotCliEditToolCall(event.data)) {
flushPendingInvocationMessages();
editToolIds.add(event.data.toolCallId);
} else {
const responsePart = processToolExecutionStart(event, pendingToolInvocations, getWorkingDirectory(this.workspace));
if (responsePart instanceof ChatResponseThinkingProgressPart) {
flushPendingInvocationMessages();
wroteResponseContent = true;
requestStream?.push(responsePart);
requestStream?.push(new ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));
} else if (responsePart instanceof ChatResponseMarkdownPart) {
// Wait for completion to push into stream.
} else if (responsePart instanceof ChatToolInvocationPart) {
responsePart.enablePartialUpdate = true;
if (isCopilotCLIToolThatCouldRequirePermissions(event)) {
toolCallWaitingForPermissions.push([responsePart, event.data as ToolCall]);
} else {
flushPendingInvocationMessages();
wroteResponseContent = true;
requestStream?.push(responsePart);
}
}
}
})));
disposables.add(toDisposable(this._sdkSession.on('tool.execution_complete', (event) => {
const toolCall = toolCalls.get(event.data.toolCallId);
const toolName = toolCall?.toolName || '<unknown>';
if (toolName.endsWith('create_pull_request') && event.data.success) {
const pullRequestUrl = extractPullRequestUrlFromToolResult(event.data.result);
if (pullRequestUrl) {
this._createdPullRequestUrl = pullRequestUrl;
GenAiMetrics.incrementPullRequestCount(this._otelService);
}
}
// Emit `languageModelToolInvoked` to mirror the workbench LanguageModelToolsService event
// for the Copilot CLI agent. CLI tools execute inside the SDK and never reach
// LanguageModelToolsService, so the workbench-side emission does not fire for them.
this._sendToolInvokedTelemetry(event, toolCall, toolStartTimes, request.sessionResource);
// Log tool call to request logger
const eventError = event.data.error ? { ...event.data.error, code: event.data.error.code || '' } : undefined;
const eventData = { ...event.data, error: eventError };
this._logToolCall(event.data.toolCallId, toolName, toolCall?.arguments, eventData);
// Complete the synthesized `execute_tool` span (native CLI tools only).
this._endSyntheticToolSpan(event, syntheticToolSpans);
// Mark the end of the edit if this was an edit tool.
toolIdEditMap.set(event.data.toolCallId, editTracker.completeEdit(event.data.toolCallId));
if (editToolIds.has(event.data.toolCallId)) {
return;
}
// Just complete the tool invocation - the part was already pushed with partial updates enabled
const [responsePart,] = processToolExecutionComplete(event, pendingToolInvocations, this.logService, getWorkingDirectory(this.workspace)) ?? [];
if (responsePart) {
flushPendingInvocationMessageForToolCallId(event.data.toolCallId);
if (responsePart instanceof ChatToolInvocationPart) {
responsePart.enablePartialUpdate = true;
}
wroteResponseContent = true;
requestStream?.push(responsePart);
}
// When a sql tool execution completes that modifies the todos table,
// query the session database and update the todo list widget.
if (toolName === 'sql' && event.data.success) {
try {
const query = (toolCall?.arguments as { query?: string } | undefined)?.query ?? '';
if (isTodoRelatedSqlQuery(query)) {
const sessionDir = getCopilotCLISessionDir(this.sessionId);
this._todoSqlQuery.queryTodos(sessionDir).then(items => {
if (token.isCancellationRequested) {
return;
}
return updateTodoListFromSqlItems(items, this._toolsService, request.toolInvocationToken, token);
}).catch(err => {
this.logService.error(err, '[CopilotCLISession] Failed to query todos from session database');
});
}
} catch (ex) {
this.logService.error(ex, `[CopilotCLISession] Failed to process completed sql tool call for todos`);
}
}
})));
disposables.add(toDisposable(this._sdkSession.on('session.error', (event) => {
flushPendingInvocationMessages();
this.logService.error(`[CopilotCLISession]CopilotCLI error: (${event.data.errorType}), ${event.data.message}`);
if (event.data.errorType === 'quota' || event.data.statusCode === 402) {
isQuotaError = true;
} else {
requestStream?.markdown(l10n.t('\n\nError: ({0}) {1}', event.data.errorType, event.data.message));
}
const errorMarkdown = [`# Error Details`, `Type: ${event.data.errorType}`, `Message: ${event.data.message}`, `## Stack`, event.data.stack || ''].join('\n');
this._requestLogger.addEntry({
type: LoggedRequestKind.MarkdownContentRequest,
debugName: `Session Error`,
startTimeMs: Date.now(),
icon: Codicon.error,
markdownContent: errorMarkdown,
isConversationRequest: true
});
})));
disposables.add(toDisposable(this._sdkSession.on('subagent.started', (event) => {
enrichToolInvocationWithSubagentMetadata(
event.data.toolCallId,
event.data.agentDisplayName,
event.data.agentDescription,
pendingToolInvocations
);
})));
disposables.add(toDisposable(this._sdkSession.on('subagent.failed', (event) => {
this.logService.trace(`[CopilotCLISession] Subagent failed: ${event.data.agentDisplayName} (toolCallId: ${event.data.toolCallId})`);
})));
// Stash hook event data on the bridge processor so SDK hook spans
// are enriched with input/output details for the debug panel.
disposables.add(toDisposable(this._sdkSession.on('hook.start', (event) => {
this.logService.trace(`[CopilotCLISession] Hook ${event.data.hookType} started (${event.data.hookInvocationId})`);
let input: string | undefined;
try {
input = truncateForOTel(JSON.stringify(event.data.input), this._otelService.config.maxAttributeSizeChars);
} catch { /* swallow serialization errors */ }
this._bridgeProcessor?.stashHookInput(event.data.hookInvocationId, event.data.hookType, input);
})));
disposables.add(toDisposable(this._sdkSession.on('hook.end', (event) => {
this.logService.trace(`[CopilotCLISession] Hook ${event.data.hookType} ended (${event.data.hookInvocationId}), success=${event.data.success}`);
const resultKind = event.data.success ? 'success' as const : 'error' as const;
let output: string | undefined;
if (event.data.success) {
try {
output = truncateForOTel(JSON.stringify(event.data.output), this._otelService.config.maxAttributeSizeChars);
} catch { /* swallow serialization errors */ }
}
this._bridgeProcessor?.stashHookEnd(
event.data.hookInvocationId,
event.data.hookType,
output,
resultKind,
event.data.error?.message,
);
})));
if (!token.isCancellationRequested) {
await this.sendRequestInternal(input, attachments, false, logStartTime);
}
if (isQuotaError) {
this._chatQuotaService.clearQuota();
let plan: string | undefined;
let isUsageBasedBilling: boolean | undefined;
let quotaResetDate: string | undefined;
try {
const copilotToken = await this._authenticationService.getCopilotToken();
plan = copilotToken.copilotPlan;
isUsageBasedBilling = copilotToken.tokenBasedBilling;
quotaResetDate = copilotToken.quotaInfo.quota_reset_date;
} catch { /* token unavailable */ }
throw new CopilotCLIQuotaExceededError(getQuotaMessageForPlan(plan, isUsageBasedBilling, quotaResetDate));
}
this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`);
const resolvedToolIdEditMap: Record<string, string> = {};
await Promise.all(Array.from(toolIdEditMap.entries()).map(async ([toolId, editFilePromise]) => {
const editId = await editFilePromise.catch(() => undefined);
if (editId) {
resolvedToolIdEditMap[toolId] = editId;
}
}));
if (sdkRequestId) {
await this._chatSessionMetadataStore.updateRequestDetails(this.sessionId, [{
vscodeRequestId: request.id,
copilotRequestId: sdkRequestId,
toolIdEditMap: resolvedToolIdEditMap,
agentId: this._agentName,
}]).catch(error => {
this.logService.error(`[CopilotCLISession] Failed to update chat session metadata store for request ${request.id}`, error);
});
}
await updateUsageInfo.catch(error => {
this.logService.error(`[CopilotCLISession] Failed to update usage info after request ${request.id}`, error);
});
this._status = ChatSessionStatus.Completed;
this._statusChange.fire(this._status);
// Log the completed conversation
this._logConversation(prompt, assistantMessageChunks.join(''), modelId || '', attachments, logStartTime, 'Completed');
} catch (error) {
if (error instanceof CopilotCLIQuotaExceededError) {
throw error;
}
if (isQuotaError) {
this._chatQuotaService.clearQuota();
let plan: string | undefined;
let isUsageBasedBilling: boolean | undefined;
let quotaResetDate: string | undefined;
try {
const copilotToken = await this._authenticationService.getCopilotToken();
plan = copilotToken.copilotPlan;
isUsageBasedBilling = copilotToken.tokenBasedBilling;
quotaResetDate = copilotToken.quotaInfo.quota_reset_date;
} catch { /* token unavailable */ }
throw new CopilotCLIQuotaExceededError(getQuotaMessageForPlan(plan, isUsageBasedBilling, quotaResetDate));
}
this._status = ChatSessionStatus.Failed;
this._statusChange.fire(this._status);
this.logService.error(`[CopilotCLISession] Invoking session (error) ${this.sessionId}`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
requestStream?.markdown(l10n.t('\n\nError: {0}', errorMessage));
invokeAgentSpan.setStatus(SpanStatusCode.ERROR, errorMessage);
if (error instanceof Error) {
invokeAgentSpan.recordException(error);
}
// Log the failed conversation
this._logConversation(prompt, assistantMessageChunks.join(''), modelId || '', attachments, logStartTime, 'Failed', errorMessage);
} finally {
cancelCancellationAbort?.();
// Synthesize a `chat` span per model turn so the chat debug logs view shows the model
// turns, token metrics, and the agent response for the in-process Copilot CLI experience,
// where the model calls happen inside the SDK and never produce JS spans. Skip when the bridge
// is installed (a future SDK with its own JS provider), since it forwards the native chat spans.
if (!this._bridgeProcessor) {
this._injectModelTurnSpans(modelTurnUsages, assistantMessageChunks.join(''), this._lastResponseModelId ?? modelId, invokeAgentTraceContext);
}
// End any synthesized tool spans that never received a completion event (e.g. on abort)
// so they don't leak.
for (const toolSpan of syntheticToolSpans.values()) {
toolSpan.setStatus(SpanStatusCode.ERROR, 'incomplete');
toolSpan.end();
}
syntheticToolSpans.clear();
// End the invoke_agent wrapper span
const durationSec = (Date.now() - logStartTime) / 1000;
invokeAgentSpan.setAttribute('copilot_chat.duration_sec', durationSec);
invokeAgentSpan.end();
this._pendingPrompt = undefined;
disposables.dispose();
this.updateArtifacts();
}
}
private async updateModel(modelId: string | undefined, reasoningEffort: string | undefined, contextTier: 'default' | 'long_context' | undefined, authInfo: NonNullable<SessionOptions['authInfo']>, token: CancellationToken): Promise<void> {
// Where possible try to avoid an extra call to getSelectedModel by using cached value.
let currentModel: string | undefined = undefined;
if (modelId) {
if (this._lastUsedModel) {
currentModel = this._lastUsedModel;
} else {
currentModel = await raceCancellation(this._sdkSession.getSelectedModel(), token);
}
}
if (token.isCancellationRequested) {
return;
}
const optionsUpdate: Record<string, unknown> = {};
if (authInfo) {
optionsUpdate.authInfo = authInfo;
}
if (contextTier) {
optionsUpdate.contextTier = contextTier;
}
if (Object.keys(optionsUpdate).length > 0) {
this._sdkSession.updateOptions(optionsUpdate);
}
if (modelId) {
if (modelId !== currentModel) {
this._lastUsedModel = modelId;
if (this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled)) {
await raceCancellation(this._sdkSession.setSelectedModel(modelId, reasoningEffort), token);
} else {
await raceCancellation(this._sdkSession.setSelectedModel(modelId), token);
}
} else if (reasoningEffort && this._sdkSession.getReasoningEffort() !== reasoningEffort && this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled)) {
await raceCancellation(this._sdkSession.setSelectedModel(modelId, reasoningEffort), token);
}
}
}
private updateArtifacts() {
const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled);
if (!shouldHandleExitPlanModeRequests || !this._toolsService.getTool('setArtifacts') || !this._toolInvocationToken) {
return;
}
const artifacts: { label: string; uri: string; type: 'devServer' | 'screenshot' | 'plan' }[] = [];
const planPath = this._sdkSession.getPlanPath();
if (planPath) {
artifacts.push({ label: l10n.t('Plan'), uri: Uri.file(planPath).toString(), type: 'plan' });
}
Promise.resolve(this._toolsService
.invokeTool('setArtifacts', { input: { artifacts }, toolInvocationToken: this._toolInvocationToken }, CancellationToken.None))
.catch(error => {
this.logService.error(error, '[CopilotCLISession] Failed to update artifacts');
});
}
/**
* Sends a request to the underlying SDK session.
*
* @param steering When `true`, the SDK send uses `mode: 'immediate'` so the
* prompt is injected into the already-running conversation rather than
* starting a new turn. This is the mechanism behind session steering.
*/
private async sendRequestInternal(input: CopilotCLISessionInput, attachments: Attachment[], steering = false, logStartTime: number): Promise<void> {
const prompt = getPromptLabel(input);
this._logRequest(prompt, this._lastUsedModel || '', attachments, logStartTime);
if ('command' in input && input.command !== 'plan') {
switch (input.command) {
case 'compact': {
this._stream?.progress(l10n.t('Compacting conversation...'));
await this._sdkSession.initializeAndValidateTools();
this._sdkSession.currentMode = 'interactive';
const result = await this._sdkSession.compactHistory();
if (result.success) {
this._stream?.markdown(l10n.t('Compacted conversation.'));
} else {
this._stream?.markdown(l10n.t('Unable to compact conversation.'));
}
break;
}
case 'fleet': {
await this._startFleetAndWaitForIdle(input);
break;
}
case 'remote': {
await this._handleRemoteControl(input);
break;
}
}
} else {
const remoteMode = isMissionControlCommandSource(input.source) ? this._mcState?.mcMode : undefined;
if (remoteMode) {
this._sdkSession.currentMode = remoteMode;
} else if ('command' in input && input.command === 'plan') {
this._sdkSession.currentMode = 'plan';
} else if (this._permissionLevel === 'autopilot') {
this._sdkSession.currentMode = 'autopilot';
} else {
this._sdkSession.currentMode = 'interactive';
}
// The sandbox only applies under default approvals — disable it for
// this request when running in a bypass-approvals mode.
const bypassApprovals = remoteMode
? remoteMode === 'autopilot'
: this._permissionLevel === 'autopilot' || this._permissionLevel === 'autoApprove';
this._applyEffectiveSandboxConfig(bypassApprovals);
const sendOptions: SendOptions = { prompt: input.prompt ?? '', attachments, agentMode: this._sdkSession.currentMode };
if (steering) {
sendOptions.mode = 'immediate';
}
if (input.source) {
sendOptions.source = input.source;
}
await this._sdkSession.send(sendOptions);
try {
const localSession = this._sdkSession as LocalSession;
if (localSession.waitForPendingBackgroundTasks) {
await localSession.waitForPendingBackgroundTasks();
}
}
catch (error) {
this.logService.error(error, '[CopilotCLISession] Error while waiting for pending background tasks');
// Don't fail the whole request if waiting for background tasks fails, as it's not critical to the main flow.
// Just log the error and continue.
}
}
}
private async _startFleetAndWaitForIdle(input: CopilotCLISessionInput): Promise<void> {
const prompt = 'prompt' in input ? input.prompt : undefined;
try {
const promise = new Promise<void>((resolve) => {
const off = this._sdkSession.on('session.idle', () => {
resolve();
off();
});
});
if (this._permissionLevel === 'autopilot') {
this._sdkSession.currentMode = 'autopilot';
} else {
this._sdkSession.currentMode = 'interactive';
}
// The sandbox only applies under default approvals — disable it when
// fleet runs in autopilot (a bypass-approvals mode).
this._applyEffectiveSandboxConfig(this._permissionLevel === 'autopilot');
const result = await this._sdkSession.fleet.start({ prompt });
if (!result.started) {
this.logService.info('[CopilotCLISession] Fleet mode not started');
return;
}
await promise;
} catch (error) {
this.logService.error(`[CopilotCLISession] Fleet error: ${error}`);
}
}
/**
* Handle `/remote` command — prints status or enables/disables Mission
* Control remote control for this session by calling the Copilot API directly.
*/
private async _handleRemoteControl(input: CopilotCLISessionInput): Promise<void> {
if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIRemoteEnabled)) {
this._stream?.markdown(l10n.t('The /remote command is not enabled. Set `github.copilot.chat.cli.remote.enabled` to `true` in settings to use it.'));
return;
}
const args = getRemoteControlArgs(input);
const isCurrentlyActive = !!this._mcState;
if (!args) {
await this._showRemoteControlStatus();
return;
}
if (args !== 'on' && args !== 'off') {
this._stream?.markdown(l10n.t('Usage: /remote, /remote on, /remote off'));
return;
}
if (args === 'on' && isCurrentlyActive) {
await this._showRemoteControlStatus();
return;
}
if (args === 'off' && !isCurrentlyActive) {
await this._showRemoteControlStatus();
return;
}
try {
if (args === 'off') {
await this._teardownRemoteControl();
this._stream?.markdown(l10n.t('Remote control disabled.'));
return;
}
this._stream?.progress(l10n.t('Enabling remote control...'));
// Step 1: Get GitHub token
const session = await this._authenticationService.getGitHubSession('any', { silent: true });
if (!session?.accessToken) {
this._stream?.markdown(l10n.t('Unable to enable remote control: no GitHub authentication available.'));
return;
}
const githubToken = session.accessToken;
// Step 2: Resolve git context (owner/repo)
const workingDir = getWorkingDirectory(this._workspaceInfo);
if (!workingDir) {
this._stream?.markdown(l10n.t('Unable to enable remote control: no workspace folder found.'));
return;
}
const nwo = await this._resolveGitHubNwo(workingDir);
if (!nwo) {
this._stream?.markdown(l10n.t('Unable to enable remote control: this workspace is not a GitHub repository.'));
return;
}
// Step 3: Resolve numeric owner/repo IDs via GitHub API
const repoResponse = await fetch(`https://api.github.com/repos/${nwo.owner}/${nwo.repo}`, {
headers: { 'Authorization': `token ${githubToken}`, 'Accept': 'application/json' },
});
if (!repoResponse.ok) {
this._stream?.markdown(l10n.t('Unable to enable remote control: could not resolve repository {0}/{1}.', nwo.owner, nwo.repo));
return;
}
const repoData = await repoResponse.json() as { id: number; owner: { id: number } };
// Step 4: Create Mission Control session
const agentTaskId = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
this.logService.trace('[CopilotCLISession] Creating MC session');
let mcData: McSessionCreateResult;
try {
mcData = await this._missionControlApiClient.createSession(repoData.owner.id, repoData.id, agentTaskId, {});
} catch (err) {
if (err instanceof PermissiveAuthRequiredError) {
this._stream?.markdown(l10n.t('Unable to enable remote control: additional GitHub permissions are required.'));
return;
}
throw err;
}
const taskId = mcData.taskId;
// Step 5: Store MC state in the shared map (keyed by SDK session ID)
// so it persists across CopilotCLISession instances.
const sharedState: McSharedState = {
mcSessionId: mcData.id,
mcFrontendUrl: undefined,
mcEventBuffer: [],
mcCompletedCommandIds: [],
mcPendingPermissionRequests: new Map(),
mcFlushInterval: undefined,
mcPollInterval: undefined,
mcLastEventId: null,
mcLastSubmitAttemptTimeMs: Date.now(),
mcProcessedCommandIds: new Set(),
mcPendingCommandCompletionIds: new Set(),
mcSdkSession: this._sdkSession,
mcEventListenerDispose: undefined,
mcSessionResource: SessionIdForCLI.getResource(this.sessionId),
};
mcStateBySessionId.set(this.sessionId, sharedState);
this.logService.trace(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${mcData.id}`);
// Step 6: Send the initial session.start event — MC requires this to
// transition out of "Fueling the runtime engines..." loading state.
const sessionStartEvent = this._createMcEvent('session.start', {
sessionId: sharedState.mcSessionId,
version: 1,
producer: 'copilot-developer-cli',
copilotVersion: '1.0.0',
startTime: new Date().toISOString(),
remoteSteerable: true,
context: {
cwd: workingDir,
gitRoot: workingDir,
repository: `${nwo.owner}/${nwo.repo}`,
},
});
sharedState.mcEventBuffer.push(sessionStartEvent);
// Also send a session.remote_steerable_changed event to explicitly
// enable steering on the MC web UI.
sharedState.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', {
remoteSteerable: true,
}));
const sessionTitle = await this._getMissionControlSessionTitle();
if (sessionTitle) {
sharedState.mcEventBuffer.push(this._createMcEvent('session.title_changed', {
title: sessionTitle,
}, true));
}
// Step 7b: Replay existing conversation history so the MC web UI
// shows all messages that occurred before /remote was invoked.
// Only replay conversation-content events — skip session lifecycle
// events that would override the remoteSteerable state we just set.
const replayableTypes = new Set([
'user.message', 'assistant.message', 'assistant.turn_start',
'assistant.turn_complete', 'tool.execution_start',
'tool.execution_complete',
]);
const existingEvents = this._sdkSession.getEvents();
let replayed = 0;
for (const event of existingEvents) {
const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null };
if (e.type && replayableTypes.has(e.type)) {
this._bufferMcEvent(e);
replayed++;
}
}
this.logService.trace(`[CopilotCLISession] Replayed ${replayed}/${existingEvents.length} existing events to MC`);
await this._flushMcEvents();
// Step 7c: Register a persistent on('*') listener on the SDK session
// so that events emitted between requests (e.g. from MC steering sends)
// are captured and forwarded to MC. Per-request listeners are disposed
// after each request completes, so this persistent listener fills the gap.
const sessionId = this.sessionId;
sharedState.mcEventListenerDispose = this._sdkSession.on('*', (event) => {
const state = mcStateBySessionId.get(sessionId);
if (!state) { return; }
// Use the static helper instead of this._bufferMcEvent to avoid
// relying on the instance that started MC (it may be stale).
const eventType = (event as { type?: string }).type ?? 'unknown';
const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean };
if (!shouldForwardMissionControlEvent(e)) {
return;
}
const updatedTitle = getMissionControlSessionTitleFromEvent(e);
if (updatedTitle) {
this._title = updatedTitle;
}
maybeAcknowledgeMissionControlCommandFromEvent(state, e);
if (e.id && e.timestamp) {
state.mcEventBuffer.push({
id: e.id,
timestamp: e.timestamp,
parentId: e.parentId ?? state.mcLastEventId ?? null,
ephemeral: e.ephemeral,
type: eventType,
data: getMissionControlEventData(e),
});
state.mcLastEventId = e.id;
} else {
const id = crypto.randomUUID();
state.mcEventBuffer.push({
id,
timestamp: new Date().toISOString(),
parentId: state.mcLastEventId ?? null,
type: eventType,
data: getMissionControlEventData(e),
});
state.mcLastEventId = id;
}
});
// Step 8: Construct and display the frontend URL
const frontendUrl = `https://github.com/${nwo.owner}/${nwo.repo}/tasks/${taskId}`;
sharedState.mcFrontendUrl = frontendUrl;
this.logService.trace(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`);
await this._showRemoteControlEnabled(frontendUrl);
// Step 9: Start continuous event exporter and command poller
this._startMcEventExporter();
this._startMcCommandPoller();
} catch (error) {
this.logService.error(`[CopilotCLISession] Remote control error: ${error}`);
this._stream?.markdown(l10n.t('Unable to enable remote control: {0}', error instanceof Error ? error.message : String(error)));
}
}
private async _showRemoteControlStatus(): Promise<void> {
const state = this._mcState;
if (!state) {
this._stream?.markdown(l10n.t('Remote control is disabled. Use /remote on to enable it.'));
return;
}
if (state.mcFrontendUrl) {
await this._showRemoteControlEnabled(state.mcFrontendUrl);
return;
}
this._stream?.markdown(l10n.t('Remote control is enabled. Use /remote off to disable it.'));
}
private async _showRemoteControlEnabled(frontendUrl: string): Promise<void> {
const banner = new MarkdownString();
banner.appendMarkdown(`**${l10n.t('Remote control is enabled.')}**\n\n${l10n.t('Use the button below to open in your browser, or scan to steer from the GitHub mobile app.')}\n\n${l10n.t('Use /remote off to disable it.')}\n\n`);
try {
const qrDataUrl = await renderRemoteControlQrCode(frontendUrl);
banner.appendMarkdown(`![${l10n.t('QR code to open this remote session in GitHub mobile')}](${qrDataUrl})`);
} catch (error) {
this.logService.error(`[CopilotCLISession] Failed to render remote control QR code: ${error instanceof Error ? error.message : String(error)}`);
banner.appendMarkdown(l10n.t('QR code could not be rendered. Open this session from any device: {0}', frontendUrl));
}
this._stream?.markdown(banner);
this._stream?.button({
command: 'vscode.open',
arguments: [Uri.parse(frontendUrl)],
title: l10n.t('Open on GitHub'),
});
}
/**
* Disable remote control for an active Mission Control session.
*/
private async _teardownRemoteControl(): Promise<void> {
// Stop local scheduling first so no more commands or periodic flushes race
// with the final disabled-state transition we send to Mission Control.
this._stopMcCommandPoller();
this._stopMcEventExporter(false);
const state = this._mcState;
if (!state) {
this.logService.info('[CopilotCLISession] No active MC session to tear down');
return;
}
// Clean up the persistent event listener
if (state.mcEventListenerDispose) {
state.mcEventListenerDispose();
state.mcEventListenerDispose = undefined;
}
for (const pendingRequest of state.mcPendingPermissionRequests.values()) {
pendingRequest.resolve({ kind: 'denied-interactively-by-user' });
}
state.mcPendingPermissionRequests.clear();
for (const pendingRequest of getMissionControlPendingUserInputRequests(state)) {
pendingRequest.resolve(undefined);
}
getMissionControlPendingUserInputRequests(state).clear();
state.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', {
remoteSteerable: false,
}));
state.mcEventBuffer.push(this._createMcEvent('session.idle', {}));
await this._flushMcEvents();
mcStateBySessionId.delete(this.sessionId);
this.logService.info(`[CopilotCLISession] Disabled MC remote control for session ${state.mcSessionId}`);
}
/**
* Parse owner/repo from the git remote URL of a working directory.
*/
private _resolveGitHubNwo(workingDirectory: vscode.Uri): Promise<{ owner: string; repo: string } | undefined> {
return new Promise((resolve) => {
cp.execFile('git', ['remote', 'get-url', 'origin'], { cwd: workingDirectory.fsPath, timeout: 5000 }, (_error, stdout) => {
if (!stdout) {
resolve(undefined);
return;
}
const url = stdout.trim();
const match = url.match(/github\.com[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
if (match?.groups) {
resolve({ owner: match.groups.owner, repo: match.groups.repo });
} else {
resolve(undefined);
}
});
});
}
// -- Mission Control event exporter -----------------------------------
/**
* Start listening to SDK events and flushing them to Mission Control.
* Events are batched and sent every 500ms.
*/
private _startMcEventExporter(): void {
this._stopMcEventExporter();
const state = this._mcState;
if (!state) { return; }
// Event buffering is handled by _bufferMcEvent(), which is called from
// the per-send on('*') handler. We only need the flush interval here.
state.mcFlushInterval = setInterval(() => {
this._flushMcEvents().catch(err => {
this.logService.warn(`[CopilotCLISession] MC event flush failed: ${err}`);
});
}, 500);
this.logService.info('[CopilotCLISession] MC event exporter started');
}
/** Stop the MC event exporter. */
private _stopMcEventExporter(clearBuffer = true): void {
const state = this._mcState;
if (state?.mcFlushInterval) {
clearInterval(state.mcFlushInterval);
state.mcFlushInterval = undefined;
}
if (state && clearBuffer) {
state.mcEventBuffer.length = 0;
}
}
/**
* Log a summary of interesting SDK session events to the extension log so
* tool inputs/outputs (including sandboxed shell results) are visible
* without needing to instrument the runtime.
*/
private _logSessionEvent(event: { type?: string; data?: unknown }): void {
if (!this.configurationService.getConfig(ConfigKey.Advanced.CLISessionEventLoggingEnabled)) {
return;
}
const type = event.type;
if (!type) {
return;
}
// Tool/permission/assistant event payloads are heterogeneous unions in the
// SDK; access fields through a loose record cast so this helper can be
// shape-agnostic.
const data = (event.data ?? {}) as Record<string, unknown>;
const get = (...keys: string[]): unknown => {
for (const key of keys) {
const value = data[key];
if (value !== undefined) {
return value;
}
}
return undefined;
};
const getNested = (key: string, sub: string): unknown => {
const value = data[key];
return value && typeof value === 'object' ? (value as Record<string, unknown>)[sub] : undefined;
};
try {
switch (type) {
case 'tool.execution_started': {
const name = getNested('toolDescription', 'name') ?? get('toolName') ?? 'unknown';
const input = truncateForLog(JSON.stringify(get('input', 'arguments') ?? {}));
this.logService.info(`[CopilotCLISession] tool.execution_started ${name} input=${input}`);
break;
}
case 'tool.execution_complete': {
const name = getNested('toolDescription', 'name') ?? get('toolName', 'toolCallId') ?? 'unknown';
const success = get('success');
const sandboxed = get('sandboxed');
const result = data.result as Record<string, unknown> | undefined;
const content = truncateForLog(typeof result?.content === 'string' ? result.content : '');
const rawError = data.error;
const errorMessage = typeof rawError === 'string'
? rawError
: rawError && typeof rawError === 'object' && typeof (rawError as Record<string, unknown>).message === 'string'
? (rawError as { message: string }).message
: undefined;
if (errorMessage) {
this.logService.warn(`[CopilotCLISession] tool.execution_complete ${name} success=${success} sandboxed=${sandboxed} error=${errorMessage} content=${content}`);
} else {
this.logService.info(`[CopilotCLISession] tool.execution_complete ${name} success=${success} sandboxed=${sandboxed} content=${content}`);
}
break;
}
case 'permission.requested': {
const kind = getNested('permissionRequest', 'kind');
this.logService.info(`[CopilotCLISession] permission.requested kind=${kind}`);
break;
}
case 'assistant.message': {
const text = truncateForLog(typeof get('content', 'text') === 'string' ? get('content', 'text') as string : '');
this.logService.debug(`[CopilotCLISession] assistant.message ${text}`);
break;
}
case 'session.error':
case 'turn.error': {
this.logService.error(`[CopilotCLISession] ${type}: ${truncateForLog(JSON.stringify(data))}`);
break;
}
default:
this.logService.trace(`[CopilotCLISession] event ${type}`);
}
} catch (e) {
this.logService.warn(`[CopilotCLISession] _logSessionEvent failed for ${type}: ${e}`);
}
}
/**
* Buffer an SDK event for Mission Control. Called from the per-send
* on('*') handler so that events are captured on every turn.
*/
private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean }): void {
const state = this._mcState;
const eventType = event.type ?? 'unknown';
if (!state) {
return;
}
if (!shouldForwardMissionControlEvent(event)) {
return;
}
const updatedTitle = getMissionControlSessionTitleFromEvent(event);
if (updatedTitle) {
this._title = updatedTitle;
}
maybeAcknowledgeMissionControlCommandFromEvent(state, event);
this.logService.trace(`[CopilotCLISession] MC buffered event: ${eventType}`);
// If the SDK event already has a UUID id, pass it through directly
// to preserve the event identity chain. Otherwise create a new event.
if (event.id && event.timestamp) {
const mcEvent: McEvent = {
id: event.id,
timestamp: event.timestamp,
parentId: event.parentId ?? state.mcLastEventId ?? null,
ephemeral: event.ephemeral,
type: eventType,
data: getMissionControlEventData(event),
};
state.mcLastEventId = event.id;
state.mcEventBuffer.push(mcEvent);
} else {
state.mcEventBuffer.push(this._createMcEvent(eventType, getMissionControlEventData(event)));
}
}
/** Create an MC event with a UUID v4 ID and parentId chain. */
private _createMcEvent(type: string, data: Record<string, unknown>, ephemeral?: boolean): McEvent {
const state = this._mcState;
const id = crypto.randomUUID();
const event: McEvent = {
id,
timestamp: new Date().toISOString(),
parentId: state?.mcLastEventId ?? null,
ephemeral,
type,
data,
};
if (state) {
state.mcLastEventId = id;
}
return event;
}
private async _getMissionControlSessionTitle(): Promise<string | undefined> {
const liveTitle = this._title?.trim();
if (liveTitle) {
return liveTitle;
}
const sessionEvents = this._sdkSession.getEvents() as readonly { type?: string; data?: unknown }[];
for (let i = sessionEvents.length - 1; i >= 0; i--) {
const eventTitle = getMissionControlSessionTitleFromEvent(sessionEvents[i]);
if (eventTitle) {
return eventTitle;
}
}
const customTitle = (await this._chatSessionMetadataStore.getCustomTitle(this.sessionId))?.trim();
if (customTitle) {
return customTitle;
}
for (const event of sessionEvents) {
if (event.type !== 'user.message') {
continue;
}
const content = typeof event.data === 'object' && event.data !== null && 'content' in event.data
? event.data.content
: undefined;
if (typeof content === 'string') {
const sanitizedContent = stripReminders(content).trim();
if (sanitizedContent.length > 0) {
return sanitizedContent;
}
}
}
const pendingTitle = this._pendingPrompt?.trim();
return pendingTitle || undefined;
}
private _waitForMcPermissionResponse(
state: McSharedState,
permissionRequest: PermissionRequest,
requestId: string,
token: CancellationToken,
): Promise<PermissionRequestResult> {
const promptId = permissionRequest.toolCallId ?? requestId;
return new Promise<PermissionRequestResult>(resolve => {
let settled = false;
const cancellationListener = token.onCancellationRequested(() => {
complete({ kind: 'denied-interactively-by-user' });
});
const complete = (result: PermissionRequestResult) => {
if (settled) {
return;
}
settled = true;
state.mcPendingPermissionRequests.delete(promptId);
cancellationListener?.dispose();
resolve(result);
};
state.mcPendingPermissionRequests.set(promptId, { resolve: complete });
});
}
private _waitForMcUserInputResponse(
state: McSharedState,
requestId: string,
toolCallId: string | undefined,
token: CancellationToken,
): Promise<UserInputResponse | undefined> {
return new Promise<UserInputResponse | undefined>(resolve => {
let settled = false;
const complete = (result: UserInputResponse | undefined) => {
if (settled) {
return;
}
settled = true;
getMissionControlPendingUserInputRequests(state).delete(pendingRequest);
cancellationListener?.dispose();
resolve(result);
};
const pendingRequest: McPendingUserInputRequest = {
requestId,
toolCallId,
resolve: complete,
};
const cancellationListener = token.onCancellationRequested(() => {
complete(undefined);
});
getMissionControlPendingUserInputRequests(state).add(pendingRequest);
});
}
/**
* Flush buffered events to the Mission Control API.
*/
private async _flushMcEvents(): Promise<void> {
const state = this._mcState;
if (!state || !state.mcSessionId) {
return;
}
const completedCommandIds = state.mcCompletedCommandIds.splice(0);
const shouldSendKeepAlive =
state.mcEventBuffer.length === 0 &&
completedCommandIds.length === 0 &&
Date.now() - state.mcLastSubmitAttemptTimeMs >= MISSION_CONTROL_KEEPALIVE_INTERVAL_MS;
if (state.mcEventBuffer.length === 0 && completedCommandIds.length === 0 && !shouldSendKeepAlive) {
return;
}
state.mcLastSubmitAttemptTimeMs = Date.now();
const events = state.mcEventBuffer.splice(0, 500);
const eventTypes = events.map(e => e.type).join(', ');
this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]${completedCommandIds.length ? ` with ${completedCommandIds.length} completed command(s)` : ''}${shouldSendKeepAlive ? ' (keepalive)' : ''}`);
try {
const success = await this._missionControlApiClient.submitEvents(state.mcSessionId, events, completedCommandIds);
if (!success) {
// Re-queue events on failure (but don't grow unbounded)
if (state.mcEventBuffer.length < 2000) {
state.mcEventBuffer.unshift(...events);
}
state.mcCompletedCommandIds.unshift(...completedCommandIds);
} else {
this.logService.info(`[CopilotCLISession] MC event flush OK: ${events.length} event(s)`);
}
} catch (err) {
state.mcCompletedCommandIds.unshift(...completedCommandIds);
this.logService.warn(`[CopilotCLISession] MC event submission error: ${err}`);
}
}
// -- Mission Control command poller -----------------------------------
/**
* Start polling Mission Control for steering commands from the web UI.
* Polls every 3 seconds.
*/
private _startMcCommandPoller(): void {
this._stopMcCommandPoller();
const state = this._mcState;
if (!state) { return; }
// Capture sessionId for use in the closure — avoid relying on `this`
// which may be a stale CopilotCLISession instance.
const sessionId = this.sessionId;
const logService = this.logService;
const missionControlApiClient = this._missionControlApiClient;
state.mcPollInterval = setInterval(() => {
const currentState = mcStateBySessionId.get(sessionId);
if (!currentState || !currentState.mcSessionId) {
return;
}
CopilotCLISession._pollMcCommandsStatic(sessionId, currentState, missionControlApiClient, logService).catch(err => {
logService.warn(`[CopilotCLISession] MC command poll failed: ${err}`);
});
}, 3000);
this.logService.info('[CopilotCLISession] MC command poller started');
}
/** Stop the MC command poller. */
private _stopMcCommandPoller(): void {
const state = this._mcState;
if (state?.mcPollInterval) {
clearInterval(state.mcPollInterval);
state.mcPollInterval = undefined;
}
}
/**
* Poll Mission Control for pending commands and process them.
* Static method to avoid capturing a stale `this` reference.
*/
private static async _pollMcCommandsStatic(sessionId: string, state: McSharedState, missionControlApiClient: MissionControlApiClient, logService: { info(msg: string): void; warn(msg: string): void }): Promise<void> {
try {
const commands = await missionControlApiClient.getPendingCommands(state.mcSessionId);
const pendingCommandIds = new Set(commands.map(cmd => cmd.id));
for (const processedId of state.mcProcessedCommandIds) {
if (!pendingCommandIds.has(processedId)) {
state.mcProcessedCommandIds.delete(processedId);
}
}
for (const cmd of commands) {
if (cmd.state !== 'in_progress' || state.mcProcessedCommandIds.has(cmd.id)) {
continue;
}
state.mcProcessedCommandIds.add(cmd.id);
logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`);
const mode = getMissionControlModeCommand(cmd.content);
if (mode) {
state.mcMode = mode;
state.mcCompletedCommandIds.push(cmd.id);
continue;
}
switch (cmd.type) {
case 'abort':
for (const pendingRequest of state.mcPendingPermissionRequests.values()) {
pendingRequest.resolve({ kind: 'denied-interactively-by-user' });
}
state.mcPendingPermissionRequests.clear();
for (const pendingRequest of getMissionControlPendingUserInputRequests(state)) {
pendingRequest.resolve(undefined);
}
getMissionControlPendingUserInputRequests(state).clear();
state.mcSdkSession.abort();
break;
case 'ask_user_response': {
let responsePayload: McAskUserResponsePayload | undefined;
const trimmedContent = cmd.content.trim();
if (trimmedContent.startsWith('{')) {
try {
const parsed = JSON.parse(trimmedContent) as unknown;
if (parsed && typeof parsed === 'object') {
responsePayload = parsed as McAskUserResponsePayload;
}
} catch (error) {
logService.warn(`[CopilotCLISession] Failed to parse MC ask_user_response payload (${cmd.id}): ${error}`);
}
}
const pendingRequest = getMissionControlPendingUserInputRequest(state, responsePayload);
if (!pendingRequest) {
logService.warn(`[CopilotCLISession] No pending MC ask_user request found for command ${cmd.id}`);
break;
}
const response = getMcAskUserResponse(responsePayload, trimmedContent);
if (!response) {
logService.warn(`[CopilotCLISession] MC ask_user response missing answer payload (${cmd.id})`);
break;
}
pendingRequest.resolve(response);
break;
}
case 'permission_response': {
const responseData = CopilotCLISession._parseMcJsonCommand<McPermissionResponseCommandData>(cmd, logService);
const promptId = responseData?.promptId;
if (!promptId) {
logService.warn(`[CopilotCLISession] MC permission response missing promptId (${cmd.id})`);
break;
}
const pendingRequest = state.mcPendingPermissionRequests.get(promptId);
if (!pendingRequest) {
logService.warn(`[CopilotCLISession] No pending MC permission request found for prompt ${promptId}`);
break;
}
pendingRequest.resolve(responseData?.approved ? { kind: 'approve-once' } : { kind: 'denied-interactively-by-user' });
break;
}
case 'user_message':
default: {
// Route steering messages through the VS Code chat UI so
// they appear in the chat panel with proper rendering.
const vsCodeApi = require('vscode') as typeof import('vscode');
getMissionControlPendingCommandCompletionIds(state).add(cmd.id);
setPendingCopilotCLIRequestContext(sessionId, {
prompt: cmd.content,
attachments: [],
source: `command-${cmd.id}`,
});
vsCodeApi.commands.executeCommand(
'workbench.action.chat.openSessionWithPrompt.copilotcli',
{
resource: state.mcSessionResource,
prompt: cmd.content,
}
).then(undefined, err => {
clearPendingCopilotCLIRequestContext(sessionId);
getMissionControlPendingCommandCompletionIds(state).delete(cmd.id);
state.mcCompletedCommandIds.push(cmd.id);
logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`);
});
break;
}
}
if (cmd.type !== 'user_message' && cmd.type !== undefined) {
state.mcCompletedCommandIds.push(cmd.id);
}
}
} catch {
// Silently ignore polling errors
}
}
private static _parseMcJsonCommand<T extends object>(cmd: McCommand, logService: { warn(msg: string): void }): T | undefined {
try {
const parsed = JSON.parse(cmd.content) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as T;
}
} catch (error) {
logService.warn(`[CopilotCLISession] Failed to parse MC command payload (${cmd.id}): ${error}`);
}
return undefined;
}
addUserMessage(content: string) {
this._sdkSession.emit('user.message', { content });
}
addUserAssistantMessage(content: string) {
this._sdkSession.emit('assistant.message', {
messageId: `msg_${Date.now()}`,
content
});
}
public getSelectedModelId() {
return this._sdkSession.getSelectedModel();
}
public getLastResponseModelId(): string | undefined {
return this._lastResponseModelId;
}
private _logRequest(userPrompt: string, modelId: string, attachments: Attachment[], startTimeMs: number): void {
const markdownContent = this._renderRequestToMarkdown(userPrompt, modelId, attachments, startTimeMs);
this._requestLogger.addEntry({
type: LoggedRequestKind.MarkdownContentRequest,
debugName: `Copilot CLI | ${truncate(userPrompt, 30)}`,
startTimeMs,
icon: ThemeIcon.fromId('worktree'),
markdownContent,
isConversationRequest: true
});
}
private _logConversation(userPrompt: string, assistantResponse: string, modelId: string, attachments: Attachment[], startTimeMs: number, status: 'Completed' | 'Failed', errorMessage?: string): void {
const markdownContent = this._renderConversationToMarkdown(userPrompt, assistantResponse, modelId, attachments, startTimeMs, status, errorMessage);
this._requestLogger.addEntry({
type: LoggedRequestKind.MarkdownContentRequest,
debugName: `Copilot CLI | ${truncate(userPrompt, 30)}`,
startTimeMs,
icon: ThemeIcon.fromId('worktree'),
markdownContent,
isConversationRequest: true
});
}
private _renderAttachments(attachments: Attachment[]): string[] {
const lines: string[] = [];
for (const attachment of attachments) {
switch (attachment.type) {
case 'github_reference': {
lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`);
break;
}
case 'github_actions_job': {
lines.push(`- ${attachment.jobName}: (${attachment.jobId}, ${attachment.type})`);
break;
}
case 'github_commit': {
lines.push(`- ${attachment.message}: (${attachment.oid}, ${attachment.type})`);
break;
}
case 'github_file': {
lines.push(`- ${attachment.path}: (${attachment.ref}, ${attachment.type})`);
break;
}
case 'github_file_diff': {
lines.push(`- ${attachment.url}: (${attachment.type})`);
break;
}
case 'github_release': {
lines.push(`- ${attachment.name}: (${attachment.tagName}, ${attachment.type})`);
break;
}
case 'github_repository': {
lines.push(`- ${attachment.repo.name}: (${attachment.url}, ${attachment.type})`);
break;
}
case 'github_tree_comparison': {
lines.push(`- ${attachment.head}: (${attachment.base}, ${attachment.type})`);
break;
}
case 'github_url': {
lines.push(`- ${attachment.url}: (${attachment.type})`);
break;
}
case 'github_snippet': {
lines.push(`- ${attachment.path}: (${attachment.type})`);
break;
}
case 'blob': {
lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`);
break;
}
case 'extension_context': {
lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`);
break;
}
default: {
lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`);
}
}
}
return lines;
}
private _renderRequestToMarkdown(userPrompt: string, modelId: string, attachments: Attachment[], startTimeMs: number): string {
const result: string[] = [];
result.push(`# Copilot CLI Session`);
result.push(``);
result.push(`## Metadata`);
result.push(`~~~`);
result.push(`sessionId : ${this.sessionId}`);
result.push(`modelId : ${modelId}`);
result.push(`isolation : ${isIsolationEnabled(this.workspace) ? 'enabled' : 'disabled'}`);
result.push(`working dir : ${getWorkingDirectory(this.workspace)?.fsPath || '<not set>'}`);
result.push(`startTime : ${new Date(startTimeMs).toISOString()}`);
result.push(`~~~`);
result.push(``);
result.push(`## User Prompt`);
result.push(`~~~`);
result.push(userPrompt);
result.push(`~~~`);
result.push(``);
result.push(`## Attachments`);
result.push(`~~~`);
result.push(...this._renderAttachments(attachments));
result.push(`~~~`);
result.push(``);
return result.join('\n');
}
private _renderPermissionToMarkdown(permissionRequest: PermissionRequest, response: string): string {
const result: string[] = [];
result.push(`# Permission Request`);
result.push(``);
result.push(`## Metadata`);
result.push(`~~~`);
result.push(`sessionId : ${this.sessionId}`);
result.push(`kind : ${permissionRequest.kind}`);
result.push(`toolCallId : ${permissionRequest.toolCallId || ''}`);
result.push(`~~~`);
result.push(``);
switch (permissionRequest.kind) {
case 'read':
result.push(`## Read Permission Details`);
result.push(`~~~`);
result.push(`path : ${permissionRequest.path}`);
result.push(`intention : ${permissionRequest.intention}`);
result.push(`~~~`);
break;
case 'write':
result.push(`## Write Permission Details`);
result.push(`~~~`);
result.push(`path : ${permissionRequest.fileName}`);
result.push(`intention : ${permissionRequest.intention}`);
result.push(`diff : ${permissionRequest.diff}`);
result.push(`~~~`);
break;
case 'mcp':
result.push(`## MCP Permission Details`);
result.push(`~~~`);
result.push(`server : ${permissionRequest.serverName}`);
result.push(`tool : ${permissionRequest.toolName} (${permissionRequest.toolTitle})`);
result.push(`readOnly : ${permissionRequest.readOnly}`);
result.push(`args : ${permissionRequest.args !== undefined ? (typeof permissionRequest.args === 'string' ? permissionRequest.args : JSON.stringify(permissionRequest.args, undefined, 2)) : ''}`);
result.push(`~~~`);
break;
case 'shell':
result.push(`## Shell Permission Details`);
result.push(`~~~`);
result.push(`command : ${permissionRequest.fullCommandText}`);
result.push(`intention : ${permissionRequest.intention}`);
result.push(`paths : ${permissionRequest.possiblePaths}`);
result.push(`urls : ${permissionRequest.possibleUrls}`);
result.push(`~~~`);
break;
case 'url':
result.push(`## URL Permission Details`);
result.push(`~~~`);
result.push(`url : ${permissionRequest.url}`);
result.push(`intention : ${permissionRequest.intention}`);
result.push(`~~~`);
break;
}
result.push(``);
result.push(`## Response`);
result.push(`~~~`);
result.push(response);
result.push(``);
return result.join('\n');
}
private _renderConversationToMarkdown(userPrompt: string, assistantResponse: string, modelId: string, attachments: Attachment[], startTimeMs: number, status: 'Completed' | 'Failed', errorMessage?: string): string {
const result: string[] = [];
result.push(`# Copilot CLI Session`);
result.push(``);
result.push(`## Metadata`);
result.push(`~~~`);
result.push(`sessionId : ${this.sessionId}`);
result.push(`status : ${status}`);
result.push(`modelId : ${modelId}`);
result.push(`isolation : ${isIsolationEnabled(this.workspace) ? 'enabled' : 'disabled'}`);
result.push(`working dir : ${getWorkingDirectory(this.workspace)?.fsPath || '<not set>'}`);
result.push(`startTime : ${new Date(startTimeMs).toISOString()}`);
result.push(`endTime : ${new Date().toISOString()}`);
result.push(`duration : ${Date.now() - startTimeMs}ms`);
if (errorMessage) {
result.push(`error : ${errorMessage}`);
}
result.push(`~~~`);
result.push(``);
result.push(`## User Prompt`);
result.push(`~~~`);
result.push(userPrompt);
result.push(`~~~`);
result.push(``);
result.push(`## Attachments`);
result.push(`~~~`);
result.push(...this._renderAttachments(attachments));
result.push(`~~~`);
result.push(``);
result.push(`## Assistant Response`);
result.push(`~~~`);
result.push(assistantResponse || '(no response)');
result.push(`~~~`);
return result.join('\n');
}
/**
* Starts a synthesized `execute_tool` OTel span for a native CLI tool call.
*
* Native CLI tools (e.g. `powershell`, `bash`, `grep`, `task`) execute inside the SDK and never
* reach the workbench tools service, so they don't otherwise produce `execute_tool` spans for the
* chat debug logs view. MCP/VS Code tools (those carrying an `mcpServerName`) already emit spans
* via the tools service and are skipped here to avoid duplicate entries.
*/
private _startSyntheticToolSpan(
event: ToolExecutionStartEvent,
syntheticToolSpans: Map<string, ISpanHandle>,
rootTraceContext: TraceContext | undefined,
): void {
const toolCall = event.data as unknown as ToolCall;
if (toolCall.mcpServerName) {
return;
}
// Nest tool calls made by a subagent under that subagent's tool span when we have it.
const parentToolCallId = event.data.parentToolCallId;
const parentContext = (parentToolCallId ? syntheticToolSpans.get(parentToolCallId)?.getSpanContext() : undefined) ?? rootTraceContext;
const toolSpan = this._otelService.startSpan(`execute_tool ${toolCall.toolName}`, {
kind: SpanKind.INTERNAL,
attributes: {
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
[GenAiAttr.CONVERSATION_ID]: this.sessionId,
[GenAiAttr.TOOL_NAME]: toolCall.toolName,
[GenAiAttr.TOOL_CALL_ID]: toolCall.toolCallId,
[CopilotChatAttr.SESSION_ID]: this.sessionId,
[CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId,
},
parentTraceContext: parentContext,
});
if (toolCall.arguments !== undefined) {
try {
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(
typeof toolCall.arguments === 'string' ? toolCall.arguments : JSON.stringify(toolCall.arguments),
this._otelService.config.maxAttributeSizeChars,
));
} catch (err) {
this.logService.trace(`[CopilotCLISession] Failed to serialize tool arguments for ${toolCall.toolName}: ${err instanceof Error ? err.message : String(err)}`);
}
}
syntheticToolSpans.set(toolCall.toolCallId, toolSpan);
}
/**
* Completes the synthesized `execute_tool` span for a native CLI tool, recording the result and
* status. No-op for tools that were not synthesized (e.g. MCP/VS Code tools).
*/
private _endSyntheticToolSpan(
event: ToolExecutionCompleteEvent,
syntheticToolSpans: Map<string, ISpanHandle>,
): void {
const toolSpan = syntheticToolSpans.get(event.data.toolCallId);
if (!toolSpan) {
return;
}
syntheticToolSpans.delete(event.data.toolCallId);
if (event.data.success) {
const content = event.data.result?.content;
if (content !== undefined) {
try {
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(
typeof content === 'string' ? content : JSON.stringify(content),
this._otelService.config.maxAttributeSizeChars,
));
} catch (err) {
this.logService.trace(`[CopilotCLISession] Failed to serialize tool result for ${event.data.toolCallId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
toolSpan.setStatus(SpanStatusCode.OK);
} else {
const errorMessage = event.data.error
? `${event.data.error.code ?? ''} ${event.data.error.message ?? ''}`.trim() || 'tool error'
: 'tool error';
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${errorMessage}`, this._otelService.config.maxAttributeSizeChars));
toolSpan.setStatus(SpanStatusCode.ERROR, errorMessage);
}
toolSpan.end();
}
/**
* Synthesizes one `chat` OTel span per model turn reported by the SDK (`assistant.usage`), carrying
* that turn's token usage and resolved model. The chat debug logs view derives an `llm_request`
* (model turn) entry from each span and the `agent_response` from the final main-agent turn's output
* messages. For the in-process Copilot CLI experience the model calls happen inside the SDK and never
* produce JS spans, so without this the model turns, token metrics, and agent response would all be
* missing from the debug logs.
*/
private _injectModelTurnSpans(turns: readonly IModelTurnUsage[], responseText: string, fallbackModelId: string | undefined, rootTraceContext: TraceContext | undefined): void {
if (turns.length === 0) {
// No usage events were reported — still surface the response if we have one.
if (responseText) {
this._emitChatSpan({}, responseText, fallbackModelId, rootTraceContext);
}
return;
}
// The assistant response belongs to the final main-agent turn (one without a parent tool call;
// turns with a parent tool call originate from subagents).
let responseTurnIndex = -1;
for (let i = turns.length - 1; i >= 0; i--) {
if (!turns[i].parentToolCallId) {
responseTurnIndex = i;
break;
}
}
for (let i = 0; i < turns.length; i++) {
this._emitChatSpan(turns[i], i === responseTurnIndex ? responseText : '', fallbackModelId, rootTraceContext);
}
}
/**
* Emits a single synthesized `chat` span for one model turn. Token usage attributes are set only when
* present, and the assistant response (`OUTPUT_MESSAGES`) is attached only to the turn that produced it.
*/
private _emitChatSpan(turn: IModelTurnUsage, responseText: string, fallbackModelId: string | undefined, rootTraceContext: TraceContext | undefined): void {
const model = turn.model ?? fallbackModelId;
const chatSpan = this._otelService.startSpan(model ? `chat ${model}` : 'chat', {
kind: SpanKind.CLIENT,
attributes: {
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.CHAT,
[GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB,
[GenAiAttr.CONVERSATION_ID]: this.sessionId,
[CopilotChatAttr.SESSION_ID]: this.sessionId,
[CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId,
...(model ? { [GenAiAttr.REQUEST_MODEL]: model } : {}),
...(typeof turn.inputTokens === 'number' ? { [GenAiAttr.USAGE_INPUT_TOKENS]: turn.inputTokens } : {}),
...(typeof turn.outputTokens === 'number' ? { [GenAiAttr.USAGE_OUTPUT_TOKENS]: turn.outputTokens } : {}),
...(typeof turn.cacheReadTokens === 'number' ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: turn.cacheReadTokens } : {}),
...(typeof turn.copilotUsageNanoAiu === 'number' ? { [CopilotChatAttr.COPILOT_USAGE_NANO_AIU]: turn.copilotUsageNanoAiu } : {}),
...(responseText ? { [GenAiAttr.OUTPUT_MESSAGES]: truncateForOTel(JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: responseText }] }]), this._otelService.config.maxAttributeSizeChars) } : {}),
},
parentTraceContext: rootTraceContext,
});
chatSpan.end();
}
private _logToolCall(toolCallId: string, toolName: string, args: unknown, eventData: { success: boolean; error?: { code: string; message: string }; result?: { content: string } }): void {
const argsStr = args !== undefined ? (typeof args === 'string' ? args : JSON.stringify(args, undefined, 2)) : '';
const resultStr = eventData.result?.content ?? '';
const errorStr = eventData.error ? `Error: ${eventData.error.code} - ${eventData.error.message}` : '';
const markdownContent = [
`# Tool Call: ${toolName}`,
``,
`## Metadata`,
`~~~`,
`toolCallId : ${toolCallId}`,
`toolName : ${toolName}`,
`success : ${eventData.success}`,
`~~~`,
``,
`## Arguments`,
`~~~`,
argsStr,
`~~~`,
``,
`## Result`,
`~~~`,
eventData.success ? resultStr : errorStr,
`~~~`,
].join('\n');
this._requestLogger.addEntry({
type: LoggedRequestKind.MarkdownContentRequest,
debugName: `Tool: ${toolName}`,
startTimeMs: Date.now(),
icon: Codicon.tools,
markdownContent,
isConversationRequest: true
});
}
private _sendToolInvokedTelemetry(
event: ToolExecutionCompleteEvent,
toolCall: ToolCall | undefined,
toolStartTimes: Map<string, number>,
sessionResource: vscode.Uri | undefined,
): void {
const { toolCallId, success, error } = event.data;
const eventToolName = 'toolName' in event.data && typeof event.data.toolName === 'string' ? event.data.toolName : undefined;
const toolName = toolCall?.toolName ?? eventToolName ?? '<unknown>';
const startTime = toolStartTimes.get(toolCallId);
toolStartTimes.delete(toolCallId);
const invocationTimeMs = startTime !== undefined ? Date.now() - startTime : undefined;
let result: 'success' | 'error' | 'userCancelled';
if (success) {
result = 'success';
} else if (error?.code === 'rejected' || error?.code === 'denied' || error?.code === 'cancelled') {
// `rejected`/`denied` come from the user denying a permission prompt; `cancelled` comes
// from request cancellation.
result = 'userCancelled';
} else {
result = 'error';
}
const toolSourceKind = toolCall?.mcpServerName ? 'mcp' : 'copilotCli';
/* __GDPR__
"languageModelToolInvoked" : {
"owner": "roblourens",
"comment": "Provides insight into the usage of language model tools invoked by agent SDKs.",
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "success | error | userCancelled" },
"chatSessionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat session resource id." },
"toolId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The tool name reported by the agent SDK." },
"toolExtensionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Always undefined for agent SDK tools." },
"toolSourceKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the tool invocation." },
"invocationTimeMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The duration of the tool invocation in milliseconds." }
}
*/
this._telemetryService.sendMSFTTelemetryEvent('languageModelToolInvoked', {
result,
chatSessionId: sessionResource?.toString(),
toolId: toolName,
toolExtensionId: undefined,
toolSourceKind,
}, invocationTimeMs !== undefined ? { invocationTimeMs } : undefined);
}
}
function extractPullRequestUrlFromToolResult(result: unknown): string | undefined {
if (!result || typeof result !== 'object') {
return undefined;
}
const { content } = result as { content?: unknown };
const text = typeof content === 'string' ? content : JSON.stringify(content);
try {
const parsed: unknown = JSON.parse(text);
if (parsed && typeof parsed === 'object' && 'url' in parsed) {
const url = (parsed as { url: unknown }).url;
if (typeof url === 'string' && isHttpUrl(url)) {
return url;
}
}
} catch {
// not JSON
}
const urlMatch = text.match(/https?:\/\/[^\s"'`,;)\]}>]+/);
if (urlMatch) {
const cleaned = urlMatch[0].replace(/[.)\]}>]+$/, '');
if (isHttpUrl(cleaned)) {
return cleaned;
}
}
return undefined;
}
function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
interface UsageInfoData {
readonly currentTokens: number;
readonly systemTokens?: number;
readonly conversationTokens?: number;
readonly toolDefinitionsTokens?: number;
readonly tokenLimit?: number;
}
/**
* Token usage for a single model turn, captured from the SDK `assistant.usage` event. Used to
* synthesize per-turn `chat` spans for the in-process Copilot CLI chat debug logs view.
*/
interface IModelTurnUsage {
readonly model?: string;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly cacheReadTokens?: number;
readonly copilotUsageNanoAiu?: number;
/** Set when the turn originates from a subagent (nested under a parent tool call). */
readonly parentToolCallId?: string;
}
/**
* Shape of a single quota snapshot on the SDK's `assistant.usage` event (`quotaSnapshots`). The
* field is marked internal-only by the SDK, so although the published types say `entitlementRequests`
* is a number and `resetDate` is a `Date`, the runtime shape can drift (e.g. a sibling SDK delivers
* `resetDate` as an ISO string). Mark the fields optional and validate at runtime below.
*/
interface ISdkQuotaSnapshot {
readonly isUnlimitedEntitlement?: boolean;
readonly entitlementRequests?: number;
readonly overage?: number;
readonly overageAllowedWithExhaustedQuota?: boolean;
readonly remainingPercentage?: number;
readonly resetDate?: Date | string;
}
/** Maps the SDK `assistant.usage` quota snapshots to the shared {@link QuotaSnapshots} shape. */
function toChatQuotaSnapshots(snapshots: Record<string, ISdkQuotaSnapshot>): QuotaSnapshots {
const result: Record<string, QuotaSnapshot> = {};
for (const [key, snapshot] of Object.entries(snapshots)) {
if (!snapshot || typeof snapshot !== 'object') {
continue;
}
const unlimited = snapshot.isUnlimitedEntitlement === true;
const entitlement = unlimited
? '-1'
: typeof snapshot.entitlementRequests === 'number' ? String(snapshot.entitlementRequests) : undefined;
if (entitlement === undefined || typeof snapshot.remainingPercentage !== 'number') {
continue;
}
result[key] = {
entitlement,
percent_remaining: snapshot.remainingPercentage,
overage_permitted: snapshot.overageAllowedWithExhaustedQuota ?? false,
overage_count: typeof snapshot.overage === 'number' ? snapshot.overage : 0,
reset_date: toResetDateIsoString(snapshot.resetDate),
};
}
return result;
}
/** Coerces an SDK `resetDate` (a `Date` per the published type, but possibly an ISO string at runtime) to an ISO string. */
function toResetDateIsoString(resetDate: Date | string | undefined): string | undefined {
if (resetDate instanceof Date) {
return resetDate.toISOString();
}
return typeof resetDate === 'string' ? resetDate : undefined;
}
function buildPromptTokenDetails(usageInfo: UsageInfoData | undefined): { category: string; label: string; percentageOfPrompt: number }[] | undefined {
if (!usageInfo || usageInfo.currentTokens <= 0) {
return undefined;
}
const details: { category: string; label: string; percentageOfPrompt: number }[] = [];
const total = usageInfo.currentTokens;
if (usageInfo.systemTokens && usageInfo.systemTokens > 0) {
details.push({
category: PromptTokenCategory.System,
label: PromptTokenLabel.SystemInstructions,
percentageOfPrompt: Math.round((usageInfo.systemTokens / total) * 100),
});
}
if (usageInfo.toolDefinitionsTokens && usageInfo.toolDefinitionsTokens > 0) {
details.push({
category: PromptTokenCategory.System,
label: PromptTokenLabel.Tools,
percentageOfPrompt: Math.round((usageInfo.toolDefinitionsTokens / total) * 100),
});
}
if (usageInfo.conversationTokens && usageInfo.conversationTokens > 0) {
details.push({
category: PromptTokenCategory.UserContext,
label: PromptTokenLabel.Messages,
percentageOfPrompt: Math.round((usageInfo.conversationTokens / total) * 100),
});
}
return details.length > 0 ? details : undefined;
}
function truncateForLog(value: unknown, maxLen = 2000): string {
const text = typeof value === 'string' ? value : String(value);
if (text.length <= maxLen) {
return text;
}
return text.slice(0, maxLen) + `… [truncated, ${text.length - maxLen} more chars]`;
}