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