Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Attachment, SendOptions, Session, SessionOptions } from '@github/copilot/sdk';6import * as l10n from '@vscode/l10n';7import * as cp from 'child_process';8import * as crypto from 'crypto';9import type * as vscode from 'vscode';10import type { ChatParticipantToolToken } from 'vscode';11import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';12import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';13import { PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService';14import { ILogService } from '../../../../platform/log/common/logService';15import { GenAiMetrics } from '../../../../platform/otel/common/genAiMetrics';16import { CopilotChatAttr, GenAiAttr, GenAiOperationName, GenAiProviderName, IOTelService, ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel, resolveWorkspaceOTelMetadata, workspaceMetadataToOTelAttributes } from '../../../../platform/otel/common/index';17import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken';18import { IRequestLogger, LoggedRequestKind } from '../../../../platform/requestLogger/common/requestLogger';19import { PromptTokenCategory, PromptTokenLabel } from '../../../../platform/tokenizer/node/promptTokenDetails';20import { IGitService } from '../../../../platform/git/common/gitService';21import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';22import { raceCancellation } from '../../../../util/vs/base/common/async';23import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';24import { Codicon } from '../../../../util/vs/base/common/codicons';25import { Emitter } from '../../../../util/vs/base/common/event';26import { DisposableStore, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';27import { truncate } from '../../../../util/vs/base/common/strings';28import { ThemeIcon } from '../../../../util/vs/base/common/themables';29import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';30import { ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatSessionStatus, ChatToolInvocationPart, EventEmitter, MarkdownString, Uri } from '../../../../vscodeTypes';31import { IToolsService } from '../../../tools/common/toolsService';32import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';33import { ExternalEditTracker } from '../../common/externalEditTracker';34import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';35import { clearTodoList, enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, stripReminders, ToolCall, updateTodoListFromSqlItems } from '../common/copilotCLITools';36import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext } from '../common/pendingRequestContext';37import { getCopilotCLISessionDir } from './cliHelpers';38import { SessionIdForCLI } from '../common/utils';39import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';40import { ICopilotCLIImageSupport } from './copilotCLIImageSupport';41import { handleExitPlanMode } from './exitPlanModeHandler';42import { type McCommand, type McEvent, type McSessionCreateResult, MissionControlApiClient } from './missionControlApiClient';43import { handleMcpPermission, handleReadPermission, handleShellPermission, handleWritePermission, type PermissionRequest, type PermissionRequestResult, showInteractivePermissionPrompt } from './permissionHelpers';44import { TodoSqlQuery } from './todoSqlQuery';45import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from './userInputHelpers';4647/**48* Known commands that can be sent to a CopilotCLI session instead of a free-form prompt.49*/50export type CopilotCLICommand = 'compact' | 'plan' | 'fleet' | 'remote';5152/**53* The set of all known CopilotCLI commands. Used by callers that need to54* distinguish a slash-command from a regular prompt at runtime.55*/56export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'plan', 'fleet', 'remote'] as const;5758/**59* Shared Mission Control state keyed by SDK session ID.60* CopilotCLISession instances are recreated per request, so MC state61* must be stored externally to persist across turns.62*/63interface McSharedState {64mcSessionId: string;65mcFrontendUrl?: string;66mcEventBuffer: McEvent[];67mcCompletedCommandIds: string[];68mcPendingPermissionRequests: Map<string, { resolve(result: PermissionRequestResult): void }>;69mcPendingUserInputRequests?: Set<McPendingUserInputRequest>;70mcFlushInterval: ReturnType<typeof setInterval> | undefined;71mcPollInterval: ReturnType<typeof setInterval> | undefined;72mcLastEventId: string | null;73mcLastSubmitAttemptTimeMs: number;74mcProcessedCommandIds: Set<string>;75mcPendingCommandCompletionIds?: Set<string>;76/** Reference to the SDK session for steering from the command poller. */77mcSdkSession: Session;78/** Dispose function for the persistent on('*') listener for MC events. */79mcEventListenerDispose: (() => void) | undefined;80/** VS Code session resource URI for routing steering through the chat UI. */81mcSessionResource: import('vscode').Uri;82}83const mcStateBySessionId = new Map<string, McSharedState>();8485const MISSION_CONTROL_KEEPALIVE_INTERVAL_MS = 10_000;8687interface McPermissionResponseCommandData {88readonly promptId?: string;89readonly approved?: boolean;90readonly scope?: 'once' | 'session';91}9293interface UserInputResponse {94readonly answer: string;95readonly wasFreeform: boolean;96}9798interface McPendingUserInputRequest {99readonly requestId: string;100readonly toolCallId?: string;101resolve(result: UserInputResponse | undefined): void;102}103104interface McAskUserResponsePayload {105readonly requestId?: string;106readonly promptId?: string;107readonly toolCallId?: string;108readonly answer?: string;109readonly wasFreeform?: boolean;110readonly freeText?: string | null;111readonly selected?: readonly string[];112readonly skipped?: boolean;113readonly response?: {114readonly answer?: string;115readonly wasFreeform?: boolean;116readonly freeText?: string | null;117readonly selected?: readonly string[];118readonly skipped?: boolean;119};120}121122const skippedMissionControlEventTypes = new Set([123'assistant.message_delta',124'assistant.streaming_delta',125'session.shutdown',126'session.error',127'session.usage_info',128'assistant.usage',129'pending_messages.modified',130'session.mcp_server_status_changed',131'session.mcp_servers_loaded',132'session.skills_loaded',133'session.tools_updated',134]);135136function shouldForwardMissionControlEvent(event: { type?: string; data?: unknown }): boolean {137const eventType = event.type ?? 'unknown';138if (skippedMissionControlEventTypes.has(eventType)) {139return false;140}141142if (eventType === 'tool.execution_start' || eventType === 'tool.execution_complete') {143const toolName = typeof event.data === 'object' && event.data !== null && 'toolName' in event.data144? event.data.toolName145: undefined;146if (toolName === 'report_intent') {147return false;148}149}150151return true;152}153154function getMissionControlCommandIdFromEvent(event: { type?: string; data?: unknown }): string | undefined {155if (event.type !== 'user.message') {156return undefined;157}158159const source = typeof event.data === 'object' && event.data !== null && 'source' in event.data160? event.data.source161: undefined;162return typeof source === 'string' && source.startsWith('command-')163? source.slice('command-'.length)164: undefined;165}166167function getMissionControlSessionTitleFromEvent(event: { type?: string; data?: unknown }): string | undefined {168if (event.type !== 'session.title_changed') {169return undefined;170}171172const title = typeof event.data === 'object' && event.data !== null && 'title' in event.data173? event.data.title174: undefined;175return typeof title === 'string' && title.trim().length > 0 ? title : undefined;176}177178function getMissionControlEventData(event: { type?: string; data?: unknown }): Record<string, unknown> {179if (!event.data || typeof event.data !== 'object') {180return {};181}182183const data = event.data as Record<string, unknown>;184if (event.type === 'user.message') {185const content = data.content;186if (typeof content !== 'string') {187return data;188}189190const sanitizedContent = stripReminders(content);191return sanitizedContent === content ? data : { ...data, content: sanitizedContent };192}193194if (event.type !== 'tool.execution_start') {195return data;196}197198const toolName = data.toolName;199if (toolName !== 'bash' && toolName !== 'powershell' && toolName !== 'task') {200return data;201}202203const args = data.arguments;204if (!args || typeof args !== 'object' || !('description' in args)) {205return data;206}207208const { description: _description, ...sanitizedArgs } = args as Record<string, unknown>;209return { ...data, arguments: sanitizedArgs };210}211212function getMissionControlPendingCommandCompletionIds(state: McSharedState): Set<string> {213state.mcPendingCommandCompletionIds ??= new Set();214return state.mcPendingCommandCompletionIds;215}216217function getMissionControlPendingUserInputRequests(state: McSharedState): Set<McPendingUserInputRequest> {218state.mcPendingUserInputRequests ??= new Set();219return state.mcPendingUserInputRequests;220}221222function getMissionControlPendingUserInputRequest(state: McSharedState, payload: McAskUserResponsePayload | undefined): McPendingUserInputRequest | undefined {223const pendingRequests = [...getMissionControlPendingUserInputRequests(state)];224const identifiers = [225payload?.requestId,226payload?.promptId,227payload?.toolCallId,228].filter((value): value is string => typeof value === 'string' && value.length > 0);229230if (identifiers.length > 0) {231return pendingRequests.find(request =>232identifiers.includes(request.requestId) ||233(typeof request.toolCallId === 'string' && identifiers.includes(request.toolCallId))234);235}236237return pendingRequests.length === 1 ? pendingRequests[0] : undefined;238}239240function toSdkUserInputResponse(answer: IQuestionAnswer | undefined): UserInputResponse {241if (!answer) {242return { answer: '', wasFreeform: false };243}244245if (answer.freeText) {246return { answer: answer.freeText, wasFreeform: true };247}248249return { answer: answer.selected.join(', '), wasFreeform: false };250}251252function getMcAskUserResponse(payload: McAskUserResponsePayload | undefined, rawContent: string): UserInputResponse | undefined {253const response = payload?.response ?? payload;254const answer = typeof response?.answer === 'string'255? response.answer256: typeof response?.freeText === 'string'257? response.freeText258: Array.isArray(response?.selected)259? response.selected.filter((value): value is string => typeof value === 'string').join(', ')260: response?.skipped261? ''262: payload === undefined263? rawContent264: undefined;265266if (answer === undefined) {267return undefined;268}269270return {271answer,272wasFreeform: typeof response?.wasFreeform === 'boolean'273? response.wasFreeform274: typeof response?.freeText === 'string',275};276}277278function maybeAcknowledgeMissionControlCommandFromEvent(state: McSharedState, event: { type?: string; data?: unknown }): void {279const commandId = getMissionControlCommandIdFromEvent(event);280if (!commandId) {281return;282}283284if (getMissionControlPendingCommandCompletionIds(state).delete(commandId)) {285state.mcCompletedCommandIds.push(commandId);286}287}288289export { builtinSlashCommands as builtinSlashSCommands } from '../../common/builtinSlashCommands';290291/**292* Either a free-form prompt **or** a known command.293*/294export type CopilotCLISessionInput =295| { readonly prompt: string; readonly source?: SendOptions['source'] }296| { readonly prompt?: string; readonly command: CopilotCLICommand; readonly source?: SendOptions['source'] };297298function getPromptLabel(input: CopilotCLISessionInput): string {299if ('command' in input) {300const prompt = input.prompt ?? '';301return prompt ? `/${input.command} ${prompt}` : `/${input.command}`;302}303return input.prompt;304}305306export interface ICopilotCLISession extends IDisposable {307readonly sessionId: string;308readonly title?: string;309readonly createdPullRequestUrl: string | undefined;310readonly onDidChangeTitle: vscode.Event<string>;311readonly status: vscode.ChatSessionStatus | undefined;312readonly onDidChangeStatus: vscode.Event<vscode.ChatSessionStatus | undefined>;313readonly workspace: IWorkspaceInfo;314readonly additionalWorkspaces: IWorkspaceInfo[];315readonly pendingPrompt: string | undefined;316attachStream(stream: vscode.ChatResponseStream): IDisposable;317setPermissionLevel(level: string | undefined): void;318handleRequest(319request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },320input: CopilotCLISessionInput,321attachments: Attachment[],322model: { model: string; reasoningEffort?: string } | undefined,323authInfo: NonNullable<SessionOptions['authInfo']>,324token: vscode.CancellationToken325): Promise<void>;326addUserMessage(content: string): void;327addUserAssistantMessage(content: string): void;328getSelectedModelId(): Promise<string | undefined>;329}330331export class CopilotCLISession extends DisposableStore implements ICopilotCLISession {332public readonly sessionId: string;333private _createdPullRequestUrl: string | undefined;334public get createdPullRequestUrl(): string | undefined {335return this._createdPullRequestUrl;336}337private _status?: vscode.ChatSessionStatus;338public get status(): vscode.ChatSessionStatus | undefined {339return this._status;340}341private readonly _statusChange = this.add(new EventEmitter<vscode.ChatSessionStatus | undefined>());342343public readonly onDidChangeStatus = this._statusChange.event;344345private _title?: string;346public get title(): string | undefined {347return this._title;348}349private _onDidChangeTitle = this.add(new Emitter<string>());350public onDidChangeTitle = this._onDidChangeTitle.event;351private _stream?: vscode.ChatResponseStream;352private _toolInvocationToken?: ChatParticipantToolToken;353public get sdkSession() {354return this._sdkSession;355}356public get workspace() {357return this._workspaceInfo;358}359public get additionalWorkspaces() {360return this._additionalWorkspaces;361}362private _lastUsedModel: string | undefined;363private _permissionLevel: string | undefined;364private _pendingPrompt: string | undefined;365private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined;366private readonly _todoSqlQuery = new TodoSqlQuery();367private readonly _missionControlApiClient: MissionControlApiClient;368369/** Get or create shared MC state for this SDK session. */370private get _mcState(): McSharedState | undefined {371return mcStateBySessionId.get(this.sessionId);372}373374/** Callback to propagate trace context to the SDK's OtelLifecycle. */375private _updateSdkTraceContext: ((traceparent?: string, tracestate?: string) => void) | undefined;376public get pendingPrompt(): string | undefined {377return this._pendingPrompt;378}379/** Set the bridge processor for forwarding SDK spans to the debug panel. */380setBridgeProcessor(bridge: CopilotCliBridgeSpanProcessor | undefined): void {381this._bridgeProcessor = bridge;382}383/** Set the SDK OTel trace context updater (pre-bound with sessionId). */384setSdkTraceContextUpdater(updater: ((traceparent?: string, tracestate?: string) => void) | undefined): void {385this._updateSdkTraceContext = updater;386}387constructor(388private readonly _workspaceInfo: IWorkspaceInfo,389private readonly _agentName: string | undefined,390private readonly _sdkSession: Session,391private readonly _additionalWorkspaces: IWorkspaceInfo[],392@ILogService private readonly logService: ILogService,393@IWorkspaceService private readonly workspaceService: IWorkspaceService,394@IChatSessionMetadataStore private readonly _chatSessionMetadataStore: IChatSessionMetadataStore,395@IInstantiationService private readonly instantiationService: IInstantiationService,396@IRequestLogger private readonly _requestLogger: IRequestLogger,397@ICopilotCLIImageSupport private readonly _imageSupport: ICopilotCLIImageSupport,398@IToolsService private readonly _toolsService: IToolsService,399@IUserQuestionHandler private readonly _userQuestionHandler: IUserQuestionHandler,400@IConfigurationService private readonly configurationService: IConfigurationService,401@IOTelService private readonly _otelService: IOTelService,402@IGitService private readonly _gitService: IGitService,403@IAuthenticationService private readonly _authenticationService: IAuthenticationService,404) {405super();406this.sessionId = _sdkSession.sessionId;407this._missionControlApiClient = this.instantiationService.createInstance(MissionControlApiClient);408this.add(toDisposable(() => this._todoSqlQuery.dispose()));409}410411attachStream(stream: vscode.ChatResponseStream): IDisposable {412this._stream = stream;413return toDisposable(() => {414if (this._stream === stream) {415this._stream = undefined;416}417});418}419420public setPermissionLevel(level: string | undefined): void {421this._permissionLevel = level;422}423424// TODO: This should be pre-populated when we restore a session based on its original context.425// E.g. if we're resuming a session, and it tries to read a file, we shouldn't prompt for permissions again.426/**427* Accumulated attachments across all requests in this session.428* Used for permission auto-approval: if a file was attached by the user in any429* request, read access is auto-approved for that file in subsequent turns.430*/431private readonly attachments: Attachment[] = [];432/**433* Promise chain that serialises request completion tracking.434* When a steering request arrives while a previous request is still running,435* the steering handler awaits both `previousRequest` and its own SDK send so436* that the steering message does not resolve until the original request finishes.437*/438private previousRequest: Promise<unknown> = Promise.resolve();439440/**441* Entry point for every chat request against this session.442*443* **Steering behaviour**: if the session is already busy (`InProgress` or444* `NeedsInput`), the incoming message is treated as a *steering* request.445* Steering sends the new prompt to the SDK with `mode: 'immediate'` so it is446* injected into the running conversation as additional context. The steering447* request only resolves once *both* the steering send and the original448* in-flight request have completed, keeping the session's promise chain449* consistent.450*451* When the session is idle, a normal full request is started instead.452*/453public async handleRequest(454request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },455input: CopilotCLISessionInput,456attachments: Attachment[],457model: { model: string; reasoningEffort?: string } | undefined,458authInfo: NonNullable<SessionOptions['authInfo']>,459token: vscode.CancellationToken460): Promise<void> {461if (this.isDisposed) {462throw new Error('Session disposed');463}464const label = getPromptLabel(input);465const promptLabel = truncate(label, 50);466const capturingToken = new CapturingToken(`Copilot CLI | ${promptLabel}`, 'worktree', undefined, undefined, this.sessionId);467const isAlreadyBusyWithAnotherRequest = !!this._status && (this._status === ChatSessionStatus.InProgress || this._status === ChatSessionStatus.NeedsInput);468this._toolInvocationToken = request.toolInvocationToken;469470const previousRequestSnapshot = this.previousRequest;471472const handled = this._requestLogger.captureInvocation(capturingToken, async () => {473await this.updateModel(model?.model, model?.reasoningEffort, authInfo, token);474475if (isAlreadyBusyWithAnotherRequest) {476return this._handleRequestSteering(input, attachments, model, previousRequestSnapshot, token);477} else {478return this._handleRequestImpl(request, input, attachments, model, token);479}480});481482this.previousRequest = this.previousRequest.then(() => handled);483return handled;484}485486/**487* Handles a steering request - a message sent while the session is already488* busy with a previous request.489*490* The steering prompt is sent to the SDK with `mode: 'immediate'` (via491* {@link sendRequestInternal}) so the SDK injects it into the running492* conversation as additional user context. The SDK send itself typically493* completes quickly (it only enqueues the message), but we also await494* `previousRequestPromise` so that this method does not resolve until the495* original in-flight request is fully done. This ensures callers see the496* correct session state when the returned promise settles.497*498* @param previousRequestPromise A snapshot of `this.previousRequest` captured499* *before* the promise chain was extended with the current call. Using the500* snapshot avoids a circular await that would deadlock.501*/502private async _handleRequestSteering(503input: CopilotCLISessionInput,504attachments: Attachment[],505model: { model: string; reasoningEffort?: string } | undefined,506previousRequestPromise: Promise<unknown>,507token: vscode.CancellationToken,508): Promise<void> {509this.attachments.push(...attachments);510const prompt = getPromptLabel(input);511this._pendingPrompt = prompt;512this.logService.info(`[CopilotCLISession] Steering session ${this.sessionId}`);513const disposables = new DisposableStore();514const logStartTime = Date.now();515disposables.add(token.onCancellationRequested(() => {516this._sdkSession.abort();517}));518disposables.add(toDisposable(() => this._sdkSession.abort()));519520try {521// Send the steering prompt (completes quickly) and also wait for the522// previous request to finish, so this promise settles only once all523// in-flight work is done.524await Promise.all([previousRequestPromise, this.sendRequestInternal(input, attachments, true, logStartTime)]);525this._logConversation(prompt, '', model?.model || '', attachments, logStartTime, 'Completed');526} catch (error) {527this._logConversation(prompt, '', model?.model || '', attachments, logStartTime, 'Failed', error instanceof Error ? error.message : String(error));528throw error;529} finally {530disposables.dispose();531}532}533534private async _handleRequestImpl(535request: { id: string; toolInvocationToken: ChatParticipantToolToken },536input: CopilotCLISessionInput,537attachments: Attachment[],538model: { model: string; reasoningEffort?: string } | undefined,539token: vscode.CancellationToken540): Promise<void> {541const modelId = model?.model;542const promptLabel = getPromptLabel(input);543return this._otelService.startActiveSpan(544'invoke_agent copilotcli',545{546kind: SpanKind.INTERNAL,547attributes: {548[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,549[GenAiAttr.AGENT_NAME]: 'copilotcli',550[GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB,551[GenAiAttr.CONVERSATION_ID]: this.sessionId,552[CopilotChatAttr.SESSION_ID]: this.sessionId,553[CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId,554...(modelId ? { [GenAiAttr.REQUEST_MODEL]: modelId } : {}),555[CopilotChatAttr.USER_REQUEST]: truncateForOTel(promptLabel),556...workspaceMetadataToOTelAttributes(resolveWorkspaceOTelMetadata(this._gitService)),557},558},559async span => {560// Emit user_message event so chronicle can extract turns and summary561span.addEvent('user_message', { content: truncateForOTel(promptLabel) });562563// Register the trace context so the bridge processor can inject CHAT_SESSION_ID564const traceCtx = span.getSpanContext();565if (traceCtx && this._bridgeProcessor) {566this._bridgeProcessor.registerTrace(traceCtx.traceId, this.sessionId);567}568// Propagate trace context to SDK so its spans are children of this span569if (traceCtx && this._updateSdkTraceContext) {570const traceparent = `00-${traceCtx.traceId}-${traceCtx.spanId}-01`;571this._updateSdkTraceContext(traceparent);572}573try {574return await this._handleRequestImplInner(span, request, input, attachments, modelId, token);575} finally {576if (traceCtx && this._bridgeProcessor) {577this._bridgeProcessor.unregisterTrace(traceCtx.traceId);578}579// Clear SDK trace context so it doesn't leak to next request580if (this._updateSdkTraceContext) {581this._updateSdkTraceContext(undefined);582}583}584},585);586}587588private async _handleRequestImplInner(589invokeAgentSpan: ISpanHandle,590request: { id: string; toolInvocationToken: ChatParticipantToolToken },591input: CopilotCLISessionInput,592attachments: Attachment[],593modelId: string | undefined,594token: vscode.CancellationToken595): Promise<void> {596this.attachments.push(...attachments);597const prompt = getPromptLabel(input);598this._pendingPrompt = prompt;599this.logService.info(`[CopilotCLISession] Invoking session ${this.sessionId}`);600const disposables = new DisposableStore();601const logStartTime = Date.now();602disposables.add(token.onCancellationRequested(() => {603this._sdkSession.abort();604}));605disposables.add(toDisposable(() => this._sdkSession.abort()));606607this._status = ChatSessionStatus.InProgress;608this._statusChange.fire(this._status);609610611const pendingToolInvocations = new Map<string, [ChatToolInvocationPart | ChatResponseMarkdownPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();612613const editToolIds = new Set<string>();614const toolCalls = new Map<string, ToolCall>();615const editTracker = new ExternalEditTracker();616let sdkRequestId: string | undefined;617const toolIdEditMap = new Map<string, Promise<string | undefined>>();618clearTodoList(this._toolsService, request.toolInvocationToken, token).catch(err => {619this.logService.error(err, '[CopilotCLISession] Failed to clear todo list at start of session');620});621/**622* The sequence of events from the SDK is as follows:623* tool.start -> About to run a terminal command624* permission request -> Asks user for permission to run the command625* tool.complete -> Command has completed running, contains the output or error626*627* There's a problem with this flow, we end up displaying the UI about execution in progress, even before we asked for permissions.628* 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?".629* 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.630* & if we don't get a permission request, but get some other event, then we show the "Running command..." UI immediately as before.631*/632const toolCallWaitingForPermissions: [ChatToolInvocationPart, ToolCall][] = [];633const flushPendingInvocationMessages = () => {634for (const [invocationMessage,] of toolCallWaitingForPermissions) {635this._stream?.push(invocationMessage);636}637toolCallWaitingForPermissions.length = 0;638};639// Flush only the tool invocation matching the given toolCallId, leaving other640// pending tools in the array. This prevents parallel tool calls from being641// prematurely pushed to the stream when only one of them has been approved.642const flushPendingInvocationMessageForToolCallId = (toolCallId: string | undefined) => {643if (!toolCallId) {644flushPendingInvocationMessages();645return;646}647const index = toolCallWaitingForPermissions.findIndex(([, tc]) => tc.toolCallId === toolCallId);648if (index !== -1) {649const [[invocationMessage]] = toolCallWaitingForPermissions.splice(index, 1);650this._stream?.push(invocationMessage);651}652};653654const chunkMessageIds = new Set<string>();655const assistantMessageChunks: string[] = [];656let lastUsageInfo: UsageInfoData | undefined;657const reportUsage = (promptTokens: number, completionTokens: number) => {658if (token.isCancellationRequested || !this._stream) {659return;660}661this._stream.usage({662promptTokens,663completionTokens,664promptTokenDetails: buildPromptTokenDetails(lastUsageInfo),665});666};667const updateUsageInfo = (async () => {668const metrics = await this._sdkSession.usage.getMetrics();669const promptTokens = lastUsageInfo?.currentTokens || metrics.lastCallInputTokens;670reportUsage(promptTokens, metrics.lastCallOutputTokens);671})();672try {673const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled);674disposables.add(toDisposable(this._sdkSession.on('*', (event) => {675this.logService.trace(`[CopilotCLISession] CopilotCLI Event: ${JSON.stringify(event, null, 2)}`);676this.logService.info(`[CopilotCLISession] on(*) fired: ${event.type}`);677// Forward events to Mission Control if remote control is active678this._bufferMcEvent(event);679})));680disposables.add(toDisposable(this._sdkSession.on('permission.requested', async (event) => {681const permissionRequest = event.data.permissionRequest;682const requestId = event.data.requestId;683684// Auto-approve all requests when the permission level allows it.685if (this._permissionLevel === 'autoApprove' || this._permissionLevel === 'autopilot') {686this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${this._permissionLevel})`);687this._sdkSession.respondToPermission(requestId, { kind: 'approve-once' });688return;689}690691// Resolve tool call data for the permission request.692const toolData = permissionRequest.toolCallId ? toolCalls.get(permissionRequest.toolCallId) : undefined;693const pendingData = permissionRequest.toolCallId ? pendingToolInvocations.get(permissionRequest.toolCallId) : undefined;694const toolParentCallId = pendingData ? pendingData[2] : undefined;695const toolInvocationToken = this._toolInvocationToken as unknown as never;696const resolveLocalPermissionResponse = (permissionToken: CancellationToken): Promise<PermissionRequestResult> => {697switch (permissionRequest.kind) {698case 'read':699return handleReadPermission(700this.sessionId, permissionRequest, toolParentCallId,701this.attachments, this._imageSupport, this.workspace, this.workspaceService,702this._toolsService, toolInvocationToken, this.logService, permissionToken,703);704case 'write':705return handleWritePermission(706this.sessionId, permissionRequest, toolData, toolParentCallId,707this._stream, editTracker, this.workspace, this.workspaceService,708this.instantiationService, this._toolsService, toolInvocationToken, this.logService, permissionToken,709);710case 'shell':711return handleShellPermission(712permissionRequest, toolParentCallId,713this.workspace, this._toolsService, toolInvocationToken, this.logService, permissionToken,714);715case 'mcp':716return handleMcpPermission(717permissionRequest, toolParentCallId,718this._toolsService, toolInvocationToken, this.logService, permissionToken,719);720default:721return showInteractivePermissionPrompt(722permissionRequest, toolParentCallId,723this._toolsService, toolInvocationToken, this.logService, permissionToken,724);725}726};727728try {729let response: PermissionRequestResult;730if (this._permissionLevel === 'autoApprove' || this._permissionLevel === 'autopilot') {731this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${this._permissionLevel})`);732response = { kind: 'approve-once' };733} else if (this._mcState) {734const permissionResolutionTokenSource = new CancellationTokenSource(token);735try {736response = await Promise.race([737resolveLocalPermissionResponse(permissionResolutionTokenSource.token),738this._waitForMcPermissionResponse(this._mcState, permissionRequest, requestId, permissionResolutionTokenSource.token),739]);740} finally {741permissionResolutionTokenSource.dispose(true);742}743} else {744response = await resolveLocalPermissionResponse(token);745}746747flushPendingInvocationMessageForToolCallId(permissionRequest.toolCallId);748749this._requestLogger.addEntry({750type: LoggedRequestKind.MarkdownContentRequest,751debugName: `Permission Request`,752startTimeMs: Date.now(),753icon: Codicon.question,754markdownContent: this._renderPermissionToMarkdown(permissionRequest, response.kind),755isConversationRequest: true756});757758this._sdkSession.respondToPermission(requestId, response);759}760catch (error) {761this.logService.error(error, `[CopilotCLISession] Error handling permission request of kind ${permissionRequest.kind}`);762flushPendingInvocationMessageForToolCallId(permissionRequest.toolCallId);763this._sdkSession.respondToPermission(requestId, { kind: 'denied-interactively-by-user' });764}765})));766if (shouldHandleExitPlanModeRequests) {767disposables.add(toDisposable(this._sdkSession.on('exit_plan_mode.requested', async (event) => {768this.updateArtifacts();769try {770const response = await handleExitPlanMode(771event.data,772this._sdkSession,773this._permissionLevel,774this._toolInvocationToken,775this.workspaceService,776this.logService,777this._toolsService,778token,779);780flushPendingInvocationMessages();781782this._sdkSession.respondToExitPlanMode(event.data.requestId, response);783} catch (error) {784this.logService.error(error, '[CopilotCLISession] Error handling exit plan mode');785this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false });786}787})));788}789disposables.add(toDisposable(this._sdkSession.on('user_input.requested', async (event) => {790if (!(this._toolInvocationToken as unknown)) {791this.logService.warn('[AskQuestionsTool] No tool invocation token available, cannot show question carousel');792this._sdkSession.respondToUserInput(event.data.requestId, { answer: '', wasFreeform: false });793return;794}795const userInputRequest: IQuestion = {796question: event.data.question,797options: (event.data.choices ?? []).map(c => ({ label: c })),798allowFreeformInput: event.data.allowFreeform,799header: event.data.question,800};801let response: UserInputResponse;802if (this._mcState) {803const userInputResolutionTokenSource = new CancellationTokenSource(token);804const localQuestionPromise = this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, userInputResolutionTokenSource.token, event.data.toolCallId);805const remoteQuestionPromise = this._waitForMcUserInputResponse(this._mcState, event.data.requestId, event.data.toolCallId, userInputResolutionTokenSource.token);806try {807const result = await Promise.race([808localQuestionPromise.then(answer => ({ source: 'local' as const, response: toSdkUserInputResponse(answer) })),809remoteQuestionPromise.then(result => ({ source: 'remote' as const, response: result })),810]);811if (result.source === 'remote' && result.response && event.data.toolCallId) {812await this._userQuestionHandler.notifyQuestionCarouselAnswer?.(event.data.toolCallId, userInputRequest, result.response);813}814response = result.response ?? { answer: '', wasFreeform: false };815} finally {816userInputResolutionTokenSource.dispose(true);817}818} else {819response = toSdkUserInputResponse(await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token, event.data.toolCallId));820}821flushPendingInvocationMessages();822this._sdkSession.respondToUserInput(event.data.requestId, response);823})));824disposables.add(toDisposable(this._sdkSession.on('session.title_changed', (event) => {825this._title = event.data.title;826this._onDidChangeTitle.fire(event.data.title);827})));828disposables.add(toDisposable(this._sdkSession.on('user.message', (event) => {829sdkRequestId = sdkRequestId ?? event.id;830})));831disposables.add(toDisposable(this._sdkSession.on('assistant.usage', (event) => {832if (this._stream && typeof event.data.outputTokens === 'number' && typeof event.data.inputTokens === 'number') {833reportUsage(event.data.inputTokens, event.data.outputTokens);834}835})));836disposables.add(toDisposable(this._sdkSession.on('session.usage_info', (event) => {837lastUsageInfo = {838currentTokens: event.data.currentTokens,839systemTokens: event.data.systemTokens,840conversationTokens: event.data.conversationTokens,841toolDefinitionsTokens: event.data.toolDefinitionsTokens,842tokenLimit: event.data.tokenLimit,843};844reportUsage(lastUsageInfo.currentTokens, 0);845})));846disposables.add(toDisposable(this._sdkSession.on('assistant.message_delta', (event) => {847// Support for streaming delta messages.848if (typeof event.data.deltaContent === 'string' && event.data.deltaContent.length) {849// Ensure pending invocation messages are flushed even if we skip sub-agent markdown850flushPendingInvocationMessages();851// Skip sub-agent markdown — it will be captured in the subagent tool's result852if (event.data.parentToolCallId) {853return;854}855chunkMessageIds.add(event.data.messageId);856assistantMessageChunks.push(event.data.deltaContent);857this._stream?.markdown(event.data.deltaContent);858}859})));860disposables.add(toDisposable(this._sdkSession.on('assistant.message', (event) => {861if (typeof event.data.content === 'string' && event.data.content.length && !chunkMessageIds.has(event.data.messageId)) {862// Skip sub-agent markdown — it will be captured in the subagent tool's result863if (event.data.parentToolCallId) {864return;865}866assistantMessageChunks.push(event.data.content);867flushPendingInvocationMessages();868this._stream?.markdown(event.data.content);869}870})));871disposables.add(toDisposable(this._sdkSession.on('tool.execution_start', (event) => {872toolCalls.set(event.data.toolCallId, event.data as unknown as ToolCall);873874if (isCopilotCliEditToolCall(event.data)) {875flushPendingInvocationMessages();876editToolIds.add(event.data.toolCallId);877} else {878const responsePart = processToolExecutionStart(event, pendingToolInvocations, getWorkingDirectory(this.workspace));879if (responsePart instanceof ChatResponseThinkingProgressPart) {880flushPendingInvocationMessages();881this._stream?.push(responsePart);882this._stream?.push(new ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));883} else if (responsePart instanceof ChatResponseMarkdownPart) {884// Wait for completion to push into stream.885} else if (responsePart instanceof ChatToolInvocationPart) {886responsePart.enablePartialUpdate = true;887888if (isCopilotCLIToolThatCouldRequirePermissions(event)) {889toolCallWaitingForPermissions.push([responsePart, event.data as ToolCall]);890} else {891flushPendingInvocationMessages();892this._stream?.push(responsePart);893}894}895}896this.logService.trace(`[CopilotCLISession] Start Tool ${event.data.toolName || '<unknown>'}`);897})));898disposables.add(toDisposable(this._sdkSession.on('tool.execution_complete', (event) => {899const toolName = toolCalls.get(event.data.toolCallId)?.toolName || '<unknown>';900if (toolName.endsWith('create_pull_request') && event.data.success) {901const pullRequestUrl = extractPullRequestUrlFromToolResult(event.data.result);902if (pullRequestUrl) {903this._createdPullRequestUrl = pullRequestUrl;904this.logService.trace(`[CopilotCLISession] Captured pull request URL: ${pullRequestUrl}`);905GenAiMetrics.incrementPullRequestCount(this._otelService);906}907}908// Log tool call to request logger909const eventError = event.data.error ? { ...event.data.error, code: event.data.error.code || '' } : undefined;910const eventData = { ...event.data, error: eventError };911this._logToolCall(event.data.toolCallId, toolName, toolCalls.get(event.data.toolCallId)?.arguments, eventData);912913// Mark the end of the edit if this was an edit tool.914toolIdEditMap.set(event.data.toolCallId, editTracker.completeEdit(event.data.toolCallId));915if (editToolIds.has(event.data.toolCallId)) {916this.logService.trace(`[CopilotCLISession] Completed edit tracking for toolCallId ${event.data.toolCallId}`);917return;918}919920// Just complete the tool invocation - the part was already pushed with partial updates enabled921const [responsePart,] = processToolExecutionComplete(event, pendingToolInvocations, this.logService, getWorkingDirectory(this.workspace)) ?? [];922if (responsePart) {923flushPendingInvocationMessageForToolCallId(event.data.toolCallId);924if (responsePart instanceof ChatToolInvocationPart) {925responsePart.enablePartialUpdate = true;926}927this._stream?.push(responsePart);928}929930const success = `success: ${event.data.success}`;931const error = event.data.error ? `error: ${event.data.error.code},${event.data.error.message}` : '';932const result = event.data.result ? `result: ${event.data.result?.content}` : '';933const parts = [success, error, result].filter(part => part.length > 0).join(', ');934935// When a sql tool execution completes that modifies the todos table,936// query the session database and update the todo list widget.937if (toolName === 'sql' && event.data.success) {938const toolCallData = toolCalls.get(event.data.toolCallId);939try {940const query = (toolCallData?.arguments as { query?: string } | undefined)?.query ?? '';941if (isTodoRelatedSqlQuery(query)) {942const sessionDir = getCopilotCLISessionDir(this.sessionId);943this._todoSqlQuery.queryTodos(sessionDir).then(items => {944if (token.isCancellationRequested) {945return;946}947return updateTodoListFromSqlItems(items, this._toolsService, request.toolInvocationToken, token);948}).catch(err => {949this.logService.error(err, '[CopilotCLISession] Failed to query todos from session database');950});951}952} catch (ex) {953this.logService.error(ex, `[CopilotCLISession] Failed to process completed sql tool call for todos`);954}955}956957this.logService.trace(`[CopilotCLISession]Complete Tool ${toolName}, ${parts}`);958})));959disposables.add(toDisposable(this._sdkSession.on('session.error', (event) => {960flushPendingInvocationMessages();961this.logService.error(`[CopilotCLISession]CopilotCLI error: (${event.data.errorType}), ${event.data.message}`);962this._stream?.markdown(l10n.t('\n\nError: ({0}) {1}', event.data.errorType, event.data.message));963964const errorMarkdown = [`# Error Details`, `Type: ${event.data.errorType}`, `Message: ${event.data.message}`, `## Stack`, event.data.stack || ''].join('\n');965this._requestLogger.addEntry({966type: LoggedRequestKind.MarkdownContentRequest,967debugName: `Session Error`,968startTimeMs: Date.now(),969icon: Codicon.error,970markdownContent: errorMarkdown,971isConversationRequest: true972});973})));974disposables.add(toDisposable(this._sdkSession.on('subagent.started', (event) => {975this.logService.trace(`[CopilotCLISession] Subagent started: ${event.data.agentDisplayName} (toolCallId: ${event.data.toolCallId})`);976enrichToolInvocationWithSubagentMetadata(977event.data.toolCallId,978event.data.agentDisplayName,979event.data.agentDescription,980pendingToolInvocations981);982})));983disposables.add(toDisposable(this._sdkSession.on('subagent.completed', (event) => {984this.logService.trace(`[CopilotCLISession] Subagent completed: ${event.data.agentDisplayName} (toolCallId: ${event.data.toolCallId})`);985})));986disposables.add(toDisposable(this._sdkSession.on('subagent.failed', (event) => {987this.logService.trace(`[CopilotCLISession] Subagent failed: ${event.data.agentDisplayName} (toolCallId: ${event.data.toolCallId})`);988})));989// Stash hook event data on the bridge processor so SDK hook spans990// are enriched with input/output details for the debug panel.991disposables.add(toDisposable(this._sdkSession.on('hook.start', (event) => {992this.logService.trace(`[CopilotCLISession] Hook ${event.data.hookType} started (${event.data.hookInvocationId})`);993let input: string | undefined;994try {995input = truncateForOTel(JSON.stringify(event.data.input));996} catch { /* swallow serialization errors */ }997this._bridgeProcessor?.stashHookInput(event.data.hookInvocationId, event.data.hookType, input);998})));999disposables.add(toDisposable(this._sdkSession.on('hook.end', (event) => {1000this.logService.trace(`[CopilotCLISession] Hook ${event.data.hookType} ended (${event.data.hookInvocationId}), success=${event.data.success}`);1001const resultKind = event.data.success ? 'success' as const : 'error' as const;1002let output: string | undefined;1003if (event.data.success) {1004try {1005output = truncateForOTel(JSON.stringify(event.data.output));1006} catch { /* swallow serialization errors */ }1007}1008this._bridgeProcessor?.stashHookEnd(1009event.data.hookInvocationId,1010event.data.hookType,1011output,1012resultKind,1013event.data.error?.message,1014);1015})));10161017if (!token.isCancellationRequested) {1018await this.sendRequestInternal(input, attachments, false, logStartTime);1019}1020this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`);1021const resolvedToolIdEditMap: Record<string, string> = {};1022await Promise.all(Array.from(toolIdEditMap.entries()).map(async ([toolId, editFilePromise]) => {1023const editId = await editFilePromise.catch(() => undefined);1024if (editId) {1025resolvedToolIdEditMap[toolId] = editId;1026}1027}));1028if (sdkRequestId) {1029await this._chatSessionMetadataStore.updateRequestDetails(this.sessionId, [{1030vscodeRequestId: request.id,1031copilotRequestId: sdkRequestId,1032toolIdEditMap: resolvedToolIdEditMap,1033agentId: this._agentName,1034}]).catch(error => {1035this.logService.error(`[CopilotCLISession] Failed to update chat session metadata store for request ${request.id}`, error);1036});1037}1038await updateUsageInfo.catch(error => {1039this.logService.error(`[CopilotCLISession] Failed to update usage info after request ${request.id}`, error);1040});1041this._status = ChatSessionStatus.Completed;1042this._statusChange.fire(this._status);10431044// Log the completed conversation1045this._logConversation(prompt, assistantMessageChunks.join(''), modelId || '', attachments, logStartTime, 'Completed');1046} catch (error) {1047this._status = ChatSessionStatus.Failed;1048this._statusChange.fire(this._status);1049this.logService.error(`[CopilotCLISession] Invoking session (error) ${this.sessionId}`, error);1050this._stream?.markdown(l10n.t('\n\nError: {0}', error instanceof Error ? error.message : String(error)));10511052invokeAgentSpan.setStatus(SpanStatusCode.ERROR, error instanceof Error ? error.message : String(error));1053if (error instanceof Error) {1054invokeAgentSpan.recordException(error);1055}10561057// Log the failed conversation1058this._logConversation(prompt, assistantMessageChunks.join(''), modelId || '', attachments, logStartTime, 'Failed', error instanceof Error ? error.message : String(error));1059} finally {1060// End the invoke_agent wrapper span1061const durationSec = (Date.now() - logStartTime) / 1000;1062invokeAgentSpan.setAttribute('copilot_chat.duration_sec', durationSec);1063invokeAgentSpan.end();10641065this._pendingPrompt = undefined;1066disposables.dispose();10671068this.updateArtifacts();1069}1070}10711072private async updateModel(modelId: string | undefined, reasoningEffort: string | undefined, authInfo: NonNullable<SessionOptions['authInfo']>, token: CancellationToken): Promise<void> {1073// Where possible try to avoid an extra call to getSelectedModel by using cached value.1074let currentModel: string | undefined = undefined;1075if (modelId) {1076if (this._lastUsedModel) {1077currentModel = this._lastUsedModel;1078} else {1079currentModel = await raceCancellation(this._sdkSession.getSelectedModel(), token);1080}1081}1082if (token.isCancellationRequested) {1083return;1084}1085if (authInfo) {1086this._sdkSession.setAuthInfo(authInfo);1087}1088if (modelId) {1089if (modelId !== currentModel) {1090this._lastUsedModel = modelId;1091if (this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled)) {1092await raceCancellation(this._sdkSession.setSelectedModel(modelId, reasoningEffort), token);1093} else {1094await raceCancellation(this._sdkSession.setSelectedModel(modelId), token);1095}1096} else if (reasoningEffort && this._sdkSession.getReasoningEffort() !== reasoningEffort && this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled)) {1097await raceCancellation(this._sdkSession.setSelectedModel(modelId, reasoningEffort), token);1098}1099}1100}11011102private updateArtifacts() {1103const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled);11041105if (!shouldHandleExitPlanModeRequests || !this._toolsService.getTool('setArtifacts') || !this._toolInvocationToken) {1106return;1107}11081109const artifacts: { label: string; uri: string; type: 'devServer' | 'screenshot' | 'plan' }[] = [];1110const planPath = this._sdkSession.getPlanPath();1111if (planPath) {1112artifacts.push({ label: l10n.t('Plan'), uri: Uri.file(planPath).toString(), type: 'plan' });1113}1114Promise.resolve(this._toolsService1115.invokeTool('setArtifacts', { input: { artifacts }, toolInvocationToken: this._toolInvocationToken }, CancellationToken.None))1116.catch(error => {1117this.logService.error(error, '[CopilotCLISession] Failed to update artifacts');1118});1119}1120/**1121* Sends a request to the underlying SDK session.1122*1123* @param steering When `true`, the SDK send uses `mode: 'immediate'` so the1124* prompt is injected into the already-running conversation rather than1125* starting a new turn. This is the mechanism behind session steering.1126*/1127private async sendRequestInternal(input: CopilotCLISessionInput, attachments: Attachment[], steering = false, logStartTime: number): Promise<void> {1128const prompt = getPromptLabel(input);1129this._logRequest(prompt, this._lastUsedModel || '', attachments, logStartTime);11301131if ('command' in input && input.command !== 'plan') {1132switch (input.command) {1133case 'compact': {1134this._stream?.progress(l10n.t('Compacting conversation...'));1135await this._sdkSession.initializeAndValidateTools();1136this._sdkSession.currentMode = 'interactive';1137const result = await this._sdkSession.compactHistory();1138if (result.success) {1139this._stream?.markdown(l10n.t('Compacted conversation.'));1140} else {1141this._stream?.markdown(l10n.t('Unable to compact conversation.'));1142}1143break;1144}1145case 'fleet': {1146await this._startFleetAndWaitForIdle(input);1147break;1148}1149case 'remote': {1150await this._handleRemoteControl(input);1151break;1152}1153}1154} else {1155if ('command' in input && input.command === 'plan') {1156this._sdkSession.currentMode = 'plan';1157} else if (this._permissionLevel === 'autopilot') {1158this._sdkSession.currentMode = 'autopilot';1159} else {1160this._sdkSession.currentMode = 'interactive';1161}1162const sendOptions: SendOptions = { prompt: input.prompt ?? '', attachments, agentMode: this._sdkSession.currentMode };1163if (steering) {1164sendOptions.mode = 'immediate';1165}1166if (input.source) {1167sendOptions.source = input.source;1168}1169await this._sdkSession.send(sendOptions);1170}1171}11721173private async _startFleetAndWaitForIdle(input: CopilotCLISessionInput): Promise<void> {1174const prompt = 'prompt' in input ? input.prompt : undefined;1175try {1176const promise = new Promise<void>((resolve) => {1177const off = this._sdkSession.on('session.idle', () => {1178resolve();1179off();1180});1181});1182if (this._permissionLevel === 'autopilot') {1183this._sdkSession.currentMode = 'autopilot';1184} else {1185this._sdkSession.currentMode = 'interactive';1186}1187const result = await this._sdkSession.fleet.start({ prompt });1188if (!result.started) {1189this.logService.info('[CopilotCLISession] Fleet mode not started');1190return;1191}1192await promise;1193} catch (error) {1194this.logService.error(`[CopilotCLISession] Fleet error: ${error}`);1195}1196}11971198/**1199* Handle `/remote` command — prints status or enables/disables Mission1200* Control remote control for this session by calling the Copilot API directly.1201*/1202private async _handleRemoteControl(input: CopilotCLISessionInput): Promise<void> {1203if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIRemoteEnabled)) {1204this._stream?.markdown(l10n.t('The /remote command is experimental and not enabled. Set `github.copilot.chat.cli.remote.enabled` to `true` in settings to use it.'));1205return;1206}12071208const args = ('prompt' in input ? input.prompt : '')?.trim().toLowerCase();1209const isCurrentlyActive = !!this._mcState;1210if (!args) {1211this._showRemoteControlStatus();1212return;1213}1214if (args !== 'on' && args !== 'off') {1215this._stream?.markdown(l10n.t('Usage: /remote, /remote on, /remote off'));1216return;1217}1218if (args === 'on' && isCurrentlyActive) {1219this._showRemoteControlStatus();1220return;1221}1222if (args === 'off' && !isCurrentlyActive) {1223this._showRemoteControlStatus();1224return;1225}12261227try {1228if (args === 'off') {1229await this._teardownRemoteControl();1230this._stream?.markdown(l10n.t('Remote control disabled.'));1231return;1232}12331234this._stream?.progress(l10n.t('Enabling remote control...'));12351236// Step 1: Get GitHub token1237const session = await this._authenticationService.getGitHubSession('any', { silent: true });1238if (!session?.accessToken) {1239this._stream?.markdown(l10n.t('Unable to enable remote control: no GitHub authentication available.'));1240return;1241}1242const githubToken = session.accessToken;12431244// Step 2: Resolve git context (owner/repo)1245const workingDir = getWorkingDirectory(this._workspaceInfo);1246if (!workingDir) {1247this._stream?.markdown(l10n.t('Unable to enable remote control: no workspace folder found.'));1248return;1249}12501251const nwo = await this._resolveGitHubNwo(workingDir);1252if (!nwo) {1253this._stream?.markdown(l10n.t('Unable to enable remote control: this workspace is not a GitHub repository.'));1254return;1255}12561257// Step 3: Resolve numeric owner/repo IDs via GitHub API1258const repoResponse = await fetch(`https://api.github.com/repos/${nwo.owner}/${nwo.repo}`, {1259headers: { 'Authorization': `token ${githubToken}`, 'Accept': 'application/json' },1260});1261if (!repoResponse.ok) {1262this._stream?.markdown(l10n.t('Unable to enable remote control: could not resolve repository {0}/{1}.', nwo.owner, nwo.repo));1263return;1264}1265const repoData = await repoResponse.json() as { id: number; owner: { id: number } };12661267// Step 4: Create Mission Control session1268const agentTaskId = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;1269this.logService.info('[CopilotCLISession] Creating MC session');12701271let mcData: McSessionCreateResult;1272try {1273mcData = await this._missionControlApiClient.createSession(repoData.owner.id, repoData.id, agentTaskId, {});1274} catch (err) {1275if (err instanceof PermissiveAuthRequiredError) {1276this._stream?.markdown(l10n.t('Unable to enable remote control: additional GitHub permissions are required.'));1277return;1278}1279throw err;1280}12811282const taskId = mcData.taskId;12831284// Step 5: Store MC state in the shared map (keyed by SDK session ID)1285// so it persists across CopilotCLISession instances.1286const sharedState: McSharedState = {1287mcSessionId: mcData.id,1288mcFrontendUrl: undefined,1289mcEventBuffer: [],1290mcCompletedCommandIds: [],1291mcPendingPermissionRequests: new Map(),1292mcFlushInterval: undefined,1293mcPollInterval: undefined,1294mcLastEventId: null,1295mcLastSubmitAttemptTimeMs: Date.now(),1296mcProcessedCommandIds: new Set(),1297mcPendingCommandCompletionIds: new Set(),1298mcSdkSession: this._sdkSession,1299mcEventListenerDispose: undefined,1300mcSessionResource: SessionIdForCLI.getResource(this.sessionId),1301};1302mcStateBySessionId.set(this.sessionId, sharedState);1303this.logService.info(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${mcData.id}`);13041305// Step 6: Send the initial session.start event — MC requires this to1306// transition out of "Fueling the runtime engines..." loading state.1307const sessionStartEvent = this._createMcEvent('session.start', {1308sessionId: sharedState.mcSessionId,1309version: 1,1310producer: 'copilot-developer-cli',1311copilotVersion: '1.0.0',1312startTime: new Date().toISOString(),1313remoteSteerable: true,1314context: {1315cwd: workingDir,1316gitRoot: workingDir,1317repository: `${nwo.owner}/${nwo.repo}`,1318},1319});1320sharedState.mcEventBuffer.push(sessionStartEvent);13211322// Also send a session.remote_steerable_changed event to explicitly1323// enable steering on the MC web UI.1324sharedState.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', {1325remoteSteerable: true,1326}));13271328const sessionTitle = await this._getMissionControlSessionTitle();1329if (sessionTitle) {1330sharedState.mcEventBuffer.push(this._createMcEvent('session.title_changed', {1331title: sessionTitle,1332}, true));1333}13341335// Step 7b: Replay existing conversation history so the MC web UI1336// shows all messages that occurred before /remote was invoked.1337// Only replay conversation-content events — skip session lifecycle1338// events that would override the remoteSteerable state we just set.1339const replayableTypes = new Set([1340'user.message', 'assistant.message', 'assistant.turn_start',1341'assistant.turn_complete', 'tool.execution_start',1342'tool.execution_complete',1343]);1344const existingEvents = this._sdkSession.getEvents();1345let replayed = 0;1346for (const event of existingEvents) {1347const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null };1348if (e.type && replayableTypes.has(e.type)) {1349this._bufferMcEvent(e);1350replayed++;1351}1352}1353this.logService.info(`[CopilotCLISession] Replayed ${replayed}/${existingEvents.length} existing events to MC`);13541355await this._flushMcEvents();13561357// Step 7c: Register a persistent on('*') listener on the SDK session1358// so that events emitted between requests (e.g. from MC steering sends)1359// are captured and forwarded to MC. Per-request listeners are disposed1360// after each request completes, so this persistent listener fills the gap.1361const sessionId = this.sessionId;1362sharedState.mcEventListenerDispose = this._sdkSession.on('*', (event) => {1363const state = mcStateBySessionId.get(sessionId);1364if (!state) { return; }1365// Use the static helper instead of this._bufferMcEvent to avoid1366// relying on the instance that started MC (it may be stale).1367const eventType = (event as { type?: string }).type ?? 'unknown';1368const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean };1369if (!shouldForwardMissionControlEvent(e)) {1370return;1371}1372const updatedTitle = getMissionControlSessionTitleFromEvent(e);1373if (updatedTitle) {1374this._title = updatedTitle;1375}1376maybeAcknowledgeMissionControlCommandFromEvent(state, e);1377if (e.id && e.timestamp) {1378state.mcEventBuffer.push({1379id: e.id,1380timestamp: e.timestamp,1381parentId: e.parentId ?? state.mcLastEventId ?? null,1382ephemeral: e.ephemeral,1383type: eventType,1384data: getMissionControlEventData(e),1385});1386state.mcLastEventId = e.id;1387} else {1388const id = crypto.randomUUID();1389state.mcEventBuffer.push({1390id,1391timestamp: new Date().toISOString(),1392parentId: state.mcLastEventId ?? null,1393type: eventType,1394data: getMissionControlEventData(e),1395});1396state.mcLastEventId = id;1397}1398});13991400// Step 8: Construct and display the frontend URL1401const frontendUrl = `https://github.com/${nwo.owner}/${nwo.repo}/tasks/${taskId}`;1402sharedState.mcFrontendUrl = frontendUrl;1403this.logService.info(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`);14041405// Render a persistent inline info banner using the proposed1406// `stream.info()` API (blue background + blue info icon, matches1407// the native chat info notification style). The button uses1408// `vscode.open` so it opens the URL externally without invoking1409// the model, and the banner stays visible after click.1410const banner = new MarkdownString(1411`**${l10n.t('Remote control is enabled.')}** ` +1412l10n.t('You can open this session from any device.')1413);1414this._stream?.info(banner);1415this._stream?.button({1416command: 'vscode.open',1417arguments: [Uri.parse(frontendUrl)],1418title: l10n.t('Open on GitHub'),1419});14201421// Step 9: Start continuous event exporter and command poller1422this._startMcEventExporter();1423this._startMcCommandPoller();1424} catch (error) {1425this.logService.error(`[CopilotCLISession] Remote control error: ${error}`);1426this._stream?.markdown(l10n.t('Unable to enable remote control: {0}', error instanceof Error ? error.message : String(error)));1427}1428}14291430private _showRemoteControlStatus(): void {1431const state = this._mcState;1432if (!state) {1433this._stream?.markdown(l10n.t('Remote control is disabled. Use /remote on to enable it.'));1434return;1435}14361437const message = state.mcFrontendUrl1438? l10n.t('Remote control is enabled. Use /remote off to disable it. Session URL: {0}', state.mcFrontendUrl)1439: l10n.t('Remote control is enabled. Use /remote off to disable it.');1440this._stream?.markdown(message);1441if (state.mcFrontendUrl) {1442this._stream?.button({1443command: 'vscode.open',1444arguments: [Uri.parse(state.mcFrontendUrl)],1445title: l10n.t('Open on GitHub'),1446});1447}1448}14491450/**1451* Disable remote control for an active Mission Control session.1452*/1453private async _teardownRemoteControl(): Promise<void> {1454// Stop local scheduling first so no more commands or periodic flushes race1455// with the final disabled-state transition we send to Mission Control.1456this._stopMcCommandPoller();1457this._stopMcEventExporter(false);14581459const state = this._mcState;1460if (!state) {1461this.logService.info('[CopilotCLISession] No active MC session to tear down');1462return;1463}14641465// Clean up the persistent event listener1466if (state.mcEventListenerDispose) {1467state.mcEventListenerDispose();1468state.mcEventListenerDispose = undefined;1469}1470for (const pendingRequest of state.mcPendingPermissionRequests.values()) {1471pendingRequest.resolve({ kind: 'denied-interactively-by-user' });1472}1473state.mcPendingPermissionRequests.clear();1474for (const pendingRequest of getMissionControlPendingUserInputRequests(state)) {1475pendingRequest.resolve(undefined);1476}1477getMissionControlPendingUserInputRequests(state).clear();14781479state.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', {1480remoteSteerable: false,1481}));1482state.mcEventBuffer.push(this._createMcEvent('session.idle', {}));1483await this._flushMcEvents();14841485mcStateBySessionId.delete(this.sessionId);1486this.logService.info(`[CopilotCLISession] Disabled MC remote control for session ${state.mcSessionId}`);1487}14881489/**1490* Parse owner/repo from the git remote URL of a working directory.1491*/1492private _resolveGitHubNwo(workingDirectory: vscode.Uri): Promise<{ owner: string; repo: string } | undefined> {1493return new Promise((resolve) => {1494cp.execFile('git', ['remote', 'get-url', 'origin'], { cwd: workingDirectory.fsPath, timeout: 5000 }, (_error, stdout) => {1495if (!stdout) {1496resolve(undefined);1497return;1498}1499const url = stdout.trim();1500const match = url.match(/github\.com[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);1501if (match?.groups) {1502resolve({ owner: match.groups.owner, repo: match.groups.repo });1503} else {1504resolve(undefined);1505}1506});1507});1508}15091510// -- Mission Control event exporter -----------------------------------15111512/**1513* Start listening to SDK events and flushing them to Mission Control.1514* Events are batched and sent every 500ms.1515*/1516private _startMcEventExporter(): void {1517this._stopMcEventExporter();1518const state = this._mcState;1519if (!state) { return; }15201521// Event buffering is handled by _bufferMcEvent(), which is called from1522// the per-send on('*') handler. We only need the flush interval here.1523state.mcFlushInterval = setInterval(() => {1524this._flushMcEvents().catch(err => {1525this.logService.warn(`[CopilotCLISession] MC event flush failed: ${err}`);1526});1527}, 500);15281529this.logService.info('[CopilotCLISession] MC event exporter started');1530}15311532/** Stop the MC event exporter. */1533private _stopMcEventExporter(clearBuffer = true): void {1534const state = this._mcState;1535if (state?.mcFlushInterval) {1536clearInterval(state.mcFlushInterval);1537state.mcFlushInterval = undefined;1538}1539if (state && clearBuffer) {1540state.mcEventBuffer.length = 0;1541}1542}15431544/**1545* Buffer an SDK event for Mission Control. Called from the per-send1546* on('*') handler so that events are captured on every turn.1547*/1548private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean }): void {1549const state = this._mcState;1550const eventType = event.type ?? 'unknown';1551if (!state) {1552return;1553}1554if (!shouldForwardMissionControlEvent(event)) {1555return;1556}1557const updatedTitle = getMissionControlSessionTitleFromEvent(event);1558if (updatedTitle) {1559this._title = updatedTitle;1560}1561maybeAcknowledgeMissionControlCommandFromEvent(state, event);1562this.logService.trace(`[CopilotCLISession] MC buffered event: ${eventType}`);15631564// If the SDK event already has a UUID id, pass it through directly1565// to preserve the event identity chain. Otherwise create a new event.1566if (event.id && event.timestamp) {1567const mcEvent: McEvent = {1568id: event.id,1569timestamp: event.timestamp,1570parentId: event.parentId ?? state.mcLastEventId ?? null,1571ephemeral: event.ephemeral,1572type: eventType,1573data: getMissionControlEventData(event),1574};1575state.mcLastEventId = event.id;1576state.mcEventBuffer.push(mcEvent);1577} else {1578state.mcEventBuffer.push(this._createMcEvent(eventType, getMissionControlEventData(event)));1579}1580}15811582/** Create an MC event with a UUID v4 ID and parentId chain. */1583private _createMcEvent(type: string, data: Record<string, unknown>, ephemeral?: boolean): McEvent {1584const state = this._mcState;1585const id = crypto.randomUUID();1586const event: McEvent = {1587id,1588timestamp: new Date().toISOString(),1589parentId: state?.mcLastEventId ?? null,1590ephemeral,1591type,1592data,1593};1594if (state) {1595state.mcLastEventId = id;1596}1597return event;1598}15991600private async _getMissionControlSessionTitle(): Promise<string | undefined> {1601const liveTitle = this._title?.trim();1602if (liveTitle) {1603return liveTitle;1604}16051606const sessionEvents = this._sdkSession.getEvents() as readonly { type?: string; data?: unknown }[];1607for (let i = sessionEvents.length - 1; i >= 0; i--) {1608const eventTitle = getMissionControlSessionTitleFromEvent(sessionEvents[i]);1609if (eventTitle) {1610return eventTitle;1611}1612}16131614const customTitle = (await this._chatSessionMetadataStore.getCustomTitle(this.sessionId))?.trim();1615if (customTitle) {1616return customTitle;1617}16181619for (const event of sessionEvents) {1620if (event.type !== 'user.message') {1621continue;1622}1623const content = typeof event.data === 'object' && event.data !== null && 'content' in event.data1624? event.data.content1625: undefined;1626if (typeof content === 'string') {1627const sanitizedContent = stripReminders(content).trim();1628if (sanitizedContent.length > 0) {1629return sanitizedContent;1630}1631}1632}16331634const pendingTitle = this._pendingPrompt?.trim();1635return pendingTitle || undefined;1636}16371638private _waitForMcPermissionResponse(1639state: McSharedState,1640permissionRequest: PermissionRequest,1641requestId: string,1642token: CancellationToken,1643): Promise<PermissionRequestResult> {1644const promptId = permissionRequest.toolCallId ?? requestId;1645return new Promise<PermissionRequestResult>(resolve => {1646let settled = false;1647const cancellationListener = token.onCancellationRequested(() => {1648complete({ kind: 'denied-interactively-by-user' });1649});1650const complete = (result: PermissionRequestResult) => {1651if (settled) {1652return;1653}1654settled = true;1655state.mcPendingPermissionRequests.delete(promptId);1656cancellationListener?.dispose();1657resolve(result);1658};16591660state.mcPendingPermissionRequests.set(promptId, { resolve: complete });1661});1662}16631664private _waitForMcUserInputResponse(1665state: McSharedState,1666requestId: string,1667toolCallId: string | undefined,1668token: CancellationToken,1669): Promise<UserInputResponse | undefined> {1670return new Promise<UserInputResponse | undefined>(resolve => {1671let settled = false;1672const complete = (result: UserInputResponse | undefined) => {1673if (settled) {1674return;1675}1676settled = true;1677getMissionControlPendingUserInputRequests(state).delete(pendingRequest);1678cancellationListener?.dispose();1679resolve(result);1680};1681const pendingRequest: McPendingUserInputRequest = {1682requestId,1683toolCallId,1684resolve: complete,1685};1686const cancellationListener = token.onCancellationRequested(() => {1687complete(undefined);1688});16891690getMissionControlPendingUserInputRequests(state).add(pendingRequest);1691});1692}16931694/**1695* Flush buffered events to the Mission Control API.1696*/1697private async _flushMcEvents(): Promise<void> {1698const state = this._mcState;1699if (!state || !state.mcSessionId) {1700return;1701}17021703const completedCommandIds = state.mcCompletedCommandIds.splice(0);1704const shouldSendKeepAlive =1705state.mcEventBuffer.length === 0 &&1706completedCommandIds.length === 0 &&1707Date.now() - state.mcLastSubmitAttemptTimeMs >= MISSION_CONTROL_KEEPALIVE_INTERVAL_MS;1708if (state.mcEventBuffer.length === 0 && completedCommandIds.length === 0 && !shouldSendKeepAlive) {1709return;1710}17111712state.mcLastSubmitAttemptTimeMs = Date.now();1713const events = state.mcEventBuffer.splice(0, 500);17141715const eventTypes = events.map(e => e.type).join(', ');1716this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]${completedCommandIds.length ? ` with ${completedCommandIds.length} completed command(s)` : ''}${shouldSendKeepAlive ? ' (keepalive)' : ''}`);17171718try {1719const success = await this._missionControlApiClient.submitEvents(state.mcSessionId, events, completedCommandIds);1720if (!success) {1721// Re-queue events on failure (but don't grow unbounded)1722if (state.mcEventBuffer.length < 2000) {1723state.mcEventBuffer.unshift(...events);1724}1725state.mcCompletedCommandIds.unshift(...completedCommandIds);1726} else {1727this.logService.info(`[CopilotCLISession] MC event flush OK: ${events.length} event(s)`);1728}1729} catch (err) {1730state.mcCompletedCommandIds.unshift(...completedCommandIds);1731this.logService.warn(`[CopilotCLISession] MC event submission error: ${err}`);1732}1733}17341735// -- Mission Control command poller -----------------------------------17361737/**1738* Start polling Mission Control for steering commands from the web UI.1739* Polls every 3 seconds.1740*/1741private _startMcCommandPoller(): void {1742this._stopMcCommandPoller();1743const state = this._mcState;1744if (!state) { return; }17451746// Capture sessionId for use in the closure — avoid relying on `this`1747// which may be a stale CopilotCLISession instance.1748const sessionId = this.sessionId;1749const logService = this.logService;1750const missionControlApiClient = this._missionControlApiClient;17511752state.mcPollInterval = setInterval(() => {1753const currentState = mcStateBySessionId.get(sessionId);1754if (!currentState || !currentState.mcSessionId) {1755return;1756}1757CopilotCLISession._pollMcCommandsStatic(sessionId, currentState, missionControlApiClient, logService).catch(err => {1758logService.warn(`[CopilotCLISession] MC command poll failed: ${err}`);1759});1760}, 3000);17611762this.logService.info('[CopilotCLISession] MC command poller started');1763}17641765/** Stop the MC command poller. */1766private _stopMcCommandPoller(): void {1767const state = this._mcState;1768if (state?.mcPollInterval) {1769clearInterval(state.mcPollInterval);1770state.mcPollInterval = undefined;1771}1772}17731774/**1775* Poll Mission Control for pending commands and process them.1776* Static method to avoid capturing a stale `this` reference.1777*/1778private static async _pollMcCommandsStatic(sessionId: string, state: McSharedState, missionControlApiClient: MissionControlApiClient, logService: { info(msg: string): void; warn(msg: string): void }): Promise<void> {1779try {1780const commands = await missionControlApiClient.getPendingCommands(state.mcSessionId);1781const pendingCommandIds = new Set(commands.map(cmd => cmd.id));1782for (const processedId of state.mcProcessedCommandIds) {1783if (!pendingCommandIds.has(processedId)) {1784state.mcProcessedCommandIds.delete(processedId);1785}1786}17871788for (const cmd of commands) {1789if (cmd.state !== 'in_progress' || state.mcProcessedCommandIds.has(cmd.id)) {1790continue;1791}1792state.mcProcessedCommandIds.add(cmd.id);1793logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`);17941795switch (cmd.type) {1796case 'abort':1797for (const pendingRequest of state.mcPendingPermissionRequests.values()) {1798pendingRequest.resolve({ kind: 'denied-interactively-by-user' });1799}1800state.mcPendingPermissionRequests.clear();1801for (const pendingRequest of getMissionControlPendingUserInputRequests(state)) {1802pendingRequest.resolve(undefined);1803}1804getMissionControlPendingUserInputRequests(state).clear();1805state.mcSdkSession.abort();1806break;1807case 'ask_user_response': {1808let responsePayload: McAskUserResponsePayload | undefined;1809const trimmedContent = cmd.content.trim();1810if (trimmedContent.startsWith('{')) {1811try {1812const parsed = JSON.parse(trimmedContent) as unknown;1813if (parsed && typeof parsed === 'object') {1814responsePayload = parsed as McAskUserResponsePayload;1815}1816} catch (error) {1817logService.warn(`[CopilotCLISession] Failed to parse MC ask_user_response payload (${cmd.id}): ${error}`);1818}1819}18201821const pendingRequest = getMissionControlPendingUserInputRequest(state, responsePayload);1822if (!pendingRequest) {1823logService.warn(`[CopilotCLISession] No pending MC ask_user request found for command ${cmd.id}`);1824break;1825}18261827const response = getMcAskUserResponse(responsePayload, trimmedContent);1828if (!response) {1829logService.warn(`[CopilotCLISession] MC ask_user response missing answer payload (${cmd.id})`);1830break;1831}18321833pendingRequest.resolve(response);1834break;1835}1836case 'permission_response': {1837const responseData = CopilotCLISession._parseMcJsonCommand<McPermissionResponseCommandData>(cmd, logService);1838const promptId = responseData?.promptId;1839if (!promptId) {1840logService.warn(`[CopilotCLISession] MC permission response missing promptId (${cmd.id})`);1841break;1842}1843const pendingRequest = state.mcPendingPermissionRequests.get(promptId);1844if (!pendingRequest) {1845logService.warn(`[CopilotCLISession] No pending MC permission request found for prompt ${promptId}`);1846break;1847}1848pendingRequest.resolve(responseData?.approved ? { kind: 'approve-once' } : { kind: 'denied-interactively-by-user' });1849break;1850}1851case 'user_message':1852default: {1853// Route steering messages through the VS Code chat UI so1854// they appear in the chat panel with proper rendering.1855const vsCodeApi = require('vscode') as typeof import('vscode');1856getMissionControlPendingCommandCompletionIds(state).add(cmd.id);1857setPendingCopilotCLIRequestContext(sessionId, {1858prompt: cmd.content,1859attachments: [],1860source: `command-${cmd.id}`,1861});1862vsCodeApi.commands.executeCommand(1863'workbench.action.chat.openSessionWithPrompt.copilotcli',1864{1865resource: state.mcSessionResource,1866prompt: cmd.content,1867}1868).then(undefined, err => {1869clearPendingCopilotCLIRequestContext(sessionId);1870getMissionControlPendingCommandCompletionIds(state).delete(cmd.id);1871state.mcCompletedCommandIds.push(cmd.id);1872logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`);1873});1874break;1875}1876}18771878if (cmd.type !== 'user_message' && cmd.type !== undefined) {1879state.mcCompletedCommandIds.push(cmd.id);1880}1881}1882} catch {1883// Silently ignore polling errors1884}1885}18861887private static _parseMcJsonCommand<T extends object>(cmd: McCommand, logService: { warn(msg: string): void }): T | undefined {1888try {1889const parsed = JSON.parse(cmd.content) as unknown;1890if (parsed && typeof parsed === 'object') {1891return parsed as T;1892}1893} catch (error) {1894logService.warn(`[CopilotCLISession] Failed to parse MC command payload (${cmd.id}): ${error}`);1895}1896return undefined;1897}18981899addUserMessage(content: string) {1900this._sdkSession.emit('user.message', { content });1901}19021903addUserAssistantMessage(content: string) {1904this._sdkSession.emit('assistant.message', {1905messageId: `msg_${Date.now()}`,1906content1907});1908}19091910public getSelectedModelId() {1911return this._sdkSession.getSelectedModel();1912}19131914private _logRequest(userPrompt: string, modelId: string, attachments: Attachment[], startTimeMs: number): void {1915const markdownContent = this._renderRequestToMarkdown(userPrompt, modelId, attachments, startTimeMs);1916this._requestLogger.addEntry({1917type: LoggedRequestKind.MarkdownContentRequest,1918debugName: `Copilot CLI | ${truncate(userPrompt, 30)}`,1919startTimeMs,1920icon: ThemeIcon.fromId('worktree'),1921markdownContent,1922isConversationRequest: true1923});1924}19251926private _logConversation(userPrompt: string, assistantResponse: string, modelId: string, attachments: Attachment[], startTimeMs: number, status: 'Completed' | 'Failed', errorMessage?: string): void {1927const markdownContent = this._renderConversationToMarkdown(userPrompt, assistantResponse, modelId, attachments, startTimeMs, status, errorMessage);1928this._requestLogger.addEntry({1929type: LoggedRequestKind.MarkdownContentRequest,1930debugName: `Copilot CLI | ${truncate(userPrompt, 30)}`,1931startTimeMs,1932icon: ThemeIcon.fromId('worktree'),1933markdownContent,1934isConversationRequest: true1935});1936}19371938private _renderAttachments(attachments: Attachment[]): string[] {1939const lines: string[] = [];1940for (const attachment of attachments) {1941if (attachment.type === 'github_reference') {1942lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`);1943} else if (attachment.type === 'blob') {1944lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`);1945} else {1946lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`);1947}1948}1949return lines;1950}19511952private _renderRequestToMarkdown(userPrompt: string, modelId: string, attachments: Attachment[], startTimeMs: number): string {1953const result: string[] = [];1954result.push(`# Copilot CLI Session`);1955result.push(``);1956result.push(`## Metadata`);1957result.push(`~~~`);1958result.push(`sessionId : ${this.sessionId}`);1959result.push(`modelId : ${modelId}`);1960result.push(`isolation : ${isIsolationEnabled(this.workspace) ? 'enabled' : 'disabled'}`);1961result.push(`working dir : ${getWorkingDirectory(this.workspace)?.fsPath || '<not set>'}`);1962result.push(`startTime : ${new Date(startTimeMs).toISOString()}`);1963result.push(`~~~`);1964result.push(``);1965result.push(`## User Prompt`);1966result.push(`~~~`);1967result.push(userPrompt);1968result.push(`~~~`);1969result.push(``);1970result.push(`## Attachments`);1971result.push(`~~~`);1972result.push(...this._renderAttachments(attachments));1973result.push(`~~~`);1974result.push(``);1975return result.join('\n');1976}19771978private _renderPermissionToMarkdown(permissionRequest: PermissionRequest, response: string): string {1979const result: string[] = [];1980result.push(`# Permission Request`);1981result.push(``);1982result.push(`## Metadata`);1983result.push(`~~~`);1984result.push(`sessionId : ${this.sessionId}`);1985result.push(`kind : ${permissionRequest.kind}`);1986result.push(`toolCallId : ${permissionRequest.toolCallId || ''}`);1987result.push(`~~~`);1988result.push(``);1989switch (permissionRequest.kind) {1990case 'read':1991result.push(`## Read Permission Details`);1992result.push(`~~~`);1993result.push(`path : ${permissionRequest.path}`);1994result.push(`intention : ${permissionRequest.intention}`);1995result.push(`~~~`);1996break;1997case 'write':1998result.push(`## Write Permission Details`);1999result.push(`~~~`);2000result.push(`path : ${permissionRequest.fileName}`);2001result.push(`intention : ${permissionRequest.intention}`);2002result.push(`diff : ${permissionRequest.diff}`);2003result.push(`~~~`);2004break;2005case 'mcp':2006result.push(`## MCP Permission Details`);2007result.push(`~~~`);2008result.push(`server : ${permissionRequest.serverName}`);2009result.push(`tool : ${permissionRequest.toolName} (${permissionRequest.toolTitle})`);2010result.push(`readOnly : ${permissionRequest.readOnly}`);2011result.push(`args : ${permissionRequest.args !== undefined ? (typeof permissionRequest.args === 'string' ? permissionRequest.args : JSON.stringify(permissionRequest.args, undefined, 2)) : ''}`);2012result.push(`~~~`);2013break;2014case 'shell':2015result.push(`## Shell Permission Details`);2016result.push(`~~~`);2017result.push(`command : ${permissionRequest.fullCommandText}`);2018result.push(`intention : ${permissionRequest.intention}`);2019result.push(`paths : ${permissionRequest.possiblePaths}`);2020result.push(`urls : ${permissionRequest.possibleUrls}`);2021result.push(`~~~`);2022break;2023case 'url':2024result.push(`## URL Permission Details`);2025result.push(`~~~`);2026result.push(`url : ${permissionRequest.url}`);2027result.push(`intention : ${permissionRequest.intention}`);2028result.push(`~~~`);2029break;2030}2031result.push(``);2032result.push(`## Response`);2033result.push(`~~~`);2034result.push(response);2035result.push(``);2036return result.join('\n');2037}20382039private _renderConversationToMarkdown(userPrompt: string, assistantResponse: string, modelId: string, attachments: Attachment[], startTimeMs: number, status: 'Completed' | 'Failed', errorMessage?: string): string {2040const result: string[] = [];2041result.push(`# Copilot CLI Session`);2042result.push(``);2043result.push(`## Metadata`);2044result.push(`~~~`);2045result.push(`sessionId : ${this.sessionId}`);2046result.push(`status : ${status}`);2047result.push(`modelId : ${modelId}`);2048result.push(`isolation : ${isIsolationEnabled(this.workspace) ? 'enabled' : 'disabled'}`);2049result.push(`working dir : ${getWorkingDirectory(this.workspace)?.fsPath || '<not set>'}`);2050result.push(`startTime : ${new Date(startTimeMs).toISOString()}`);2051result.push(`endTime : ${new Date().toISOString()}`);2052result.push(`duration : ${Date.now() - startTimeMs}ms`);2053if (errorMessage) {2054result.push(`error : ${errorMessage}`);2055}2056result.push(`~~~`);2057result.push(``);2058result.push(`## User Prompt`);2059result.push(`~~~`);2060result.push(userPrompt);2061result.push(`~~~`);2062result.push(``);2063result.push(`## Attachments`);2064result.push(`~~~`);2065result.push(...this._renderAttachments(attachments));2066result.push(`~~~`);2067result.push(``);2068result.push(`## Assistant Response`);2069result.push(`~~~`);2070result.push(assistantResponse || '(no response)');2071result.push(`~~~`);2072return result.join('\n');2073}20742075private _logToolCall(toolCallId: string, toolName: string, args: unknown, eventData: { success: boolean; error?: { code: string; message: string }; result?: { content: string } }): void {2076const argsStr = args !== undefined ? (typeof args === 'string' ? args : JSON.stringify(args, undefined, 2)) : '';2077const resultStr = eventData.result?.content ?? '';2078const errorStr = eventData.error ? `Error: ${eventData.error.code} - ${eventData.error.message}` : '';20792080const markdownContent = [2081`# Tool Call: ${toolName}`,2082``,2083`## Metadata`,2084`~~~`,2085`toolCallId : ${toolCallId}`,2086`toolName : ${toolName}`,2087`success : ${eventData.success}`,2088`~~~`,2089``,2090`## Arguments`,2091`~~~`,2092argsStr,2093`~~~`,2094``,2095`## Result`,2096`~~~`,2097eventData.success ? resultStr : errorStr,2098`~~~`,2099].join('\n');21002101this._requestLogger.addEntry({2102type: LoggedRequestKind.MarkdownContentRequest,2103debugName: `Tool: ${toolName}`,2104startTimeMs: Date.now(),2105icon: Codicon.tools,2106markdownContent,2107isConversationRequest: true2108});2109}2110}21112112function extractPullRequestUrlFromToolResult(result: unknown): string | undefined {2113if (!result || typeof result !== 'object') {2114return undefined;2115}21162117const { content } = result as { content?: unknown };2118const text = typeof content === 'string' ? content : JSON.stringify(content);21192120try {2121const parsed: unknown = JSON.parse(text);2122if (parsed && typeof parsed === 'object' && 'url' in parsed) {2123const url = (parsed as { url: unknown }).url;2124if (typeof url === 'string' && isHttpUrl(url)) {2125return url;2126}2127}2128} catch {2129// not JSON2130}21312132const urlMatch = text.match(/https?:\/\/[^\s"'`,;)\]}>]+/);2133if (urlMatch) {2134const cleaned = urlMatch[0].replace(/[.)\]}>]+$/, '');2135if (isHttpUrl(cleaned)) {2136return cleaned;2137}2138}21392140return undefined;2141}21422143function isHttpUrl(value: string): boolean {2144try {2145const parsed = new URL(value);2146return parsed.protocol === 'https:' || parsed.protocol === 'http:';2147} catch {2148return false;2149}2150}21512152interface UsageInfoData {2153readonly currentTokens: number;2154readonly systemTokens?: number;2155readonly conversationTokens?: number;2156readonly toolDefinitionsTokens?: number;2157readonly tokenLimit?: number;2158}21592160function buildPromptTokenDetails(usageInfo: UsageInfoData | undefined): { category: string; label: string; percentageOfPrompt: number }[] | undefined {2161if (!usageInfo || usageInfo.currentTokens <= 0) {2162return undefined;2163}2164const details: { category: string; label: string; percentageOfPrompt: number }[] = [];2165const total = usageInfo.currentTokens;2166if (usageInfo.systemTokens && usageInfo.systemTokens > 0) {2167details.push({2168category: PromptTokenCategory.System,2169label: PromptTokenLabel.SystemInstructions,2170percentageOfPrompt: Math.round((usageInfo.systemTokens / total) * 100),2171});2172}2173if (usageInfo.toolDefinitionsTokens && usageInfo.toolDefinitionsTokens > 0) {2174details.push({2175category: PromptTokenCategory.System,2176label: PromptTokenLabel.Tools,2177percentageOfPrompt: Math.round((usageInfo.toolDefinitionsTokens / total) * 100),2178});2179}2180if (usageInfo.conversationTokens && usageInfo.conversationTokens > 0) {2181details.push({2182category: PromptTokenCategory.UserContext,2183label: PromptTokenLabel.Messages,2184percentageOfPrompt: Math.round((usageInfo.conversationTokens / total) * 100),2185});2186}2187return details.length > 0 ? details : undefined;2188}218921902191