Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Session, SessionOptions } from '@github/copilot/sdk';6import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';7import type { ChatParticipantToolToken } from 'vscode';8import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';9import { ILogService } from '../../../../../platform/log/common/logService';10import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index';11import { IRequestLogger } from '../../../../../platform/requestLogger/common/requestLogger';12import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger';13import { TestWorkspaceService } from '../../../../../platform/test/node/testWorkspaceService';14import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';15import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';16import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';17import * as path from '../../../../../util/vs/base/common/path';18import { URI } from '../../../../../util/vs/base/common/uri';19import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';20import { ChatSessionStatus, ChatToolInvocationPart, LanguageModelTextPart, Uri } from '../../../../../vscodeTypes';21import { createExtensionUnitTestingServices } from '../../../../test/node/services';22import { MockChatResponseStream } from '../../../../test/node/testHelpers';23import { ExternalEditTracker } from '../../../common/externalEditTracker';24import { MockChatSessionMetadataStore } from '../../../common/test/mockChatSessionMetadataStore';25import { IWorkspaceInfo } from '../../../common/workspaceInfo';26import { FakeToolsService, ToolCall } from '../../common/copilotCLITools';27import { CopilotCLISession } from '../copilotcliSession';28import { PermissionRequest } from '../permissionHelpers';29import { IQuestion, IQuestionAnswer, IUserQuestionHandler, UserInputResponse } from '../userInputHelpers';30import { NullICopilotCLIImageSupport } from './testHelpers';31import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';3233vi.mock('../cliHelpers', async (importOriginal) => ({34...(await importOriginal<typeof import('../cliHelpers')>()),35getCopilotCLISessionStateDir: () => '/mock-session-state',36}));3738// Minimal shapes for types coming from the Copilot SDK we interact with39interface MockSdkEventHandler { (payload: unknown): void }40type MockSdkEventMap = Map<string, Set<MockSdkEventHandler>>;4142class MockSdkSession {43onHandlers: MockSdkEventMap = new Map();44public sessionId = 'mock-session-id';45public _selectedModel: string | undefined = 'modelA';46public authInfo: unknown;47private _pendingPermissions = new Map<string, { resolve: (result: unknown) => void }>();48private _permissionCounter = 0;49private _pendingUserInputs = new Map<string, { resolve: (result: unknown) => void }>();50private _userInputCounter = 0;51private _pendingExitPlanMode = new Map<string, { resolve: (result: unknown) => void }>();52private _exitPlanModeCounter = 0;53public aborted = false;5455on(event: string, handler: MockSdkEventHandler) {56if (!this.onHandlers.has(event)) {57this.onHandlers.set(event, new Set());58}59this.onHandlers.get(event)!.add(handler);60return () => this.onHandlers.get(event)!.delete(handler);61}6263emit(event: string, data: unknown) {64this.onHandlers.get(event)?.forEach(h => h({ data }));65}6667/**68* Simulate the SDK emitting a permission.requested event and await the response.69* The session's event handler will call respondToPermission() which resolves the returned promise.70*/71async emitPermissionRequest(permissionRequest: PermissionRequest): Promise<unknown> {72const requestId = `perm-${++this._permissionCounter}`;73return new Promise(resolve => {74this._pendingPermissions.set(requestId, { resolve });75this.emit('permission.requested', { requestId, permissionRequest });76});77}7879respondToPermission(requestId: string, result: unknown) {80const pending = this._pendingPermissions.get(requestId);81if (pending) {82pending.resolve(result);83this._pendingPermissions.delete(requestId);84}85}8687async emitUserInputRequest(request: { question: string; choices?: string[]; allowFreeform?: boolean; toolCallId?: string }): Promise<unknown> {88const requestId = `user-input-${++this._userInputCounter}`;89return new Promise(resolve => {90this._pendingUserInputs.set(requestId, { resolve });91this.emit('user_input.requested', { requestId, ...request });92});93}9495/**96* Simulate the SDK emitting an exit_plan_mode.requested event and await the response.97* The session's event handler will call respondToExitPlanMode() which resolves the returned promise.98*/99async emitExitPlanModeRequest(data: { summary: string; actions?: string[] }): Promise<unknown> {100const requestId = `exit-plan-${++this._exitPlanModeCounter}`;101return new Promise(resolve => {102this._pendingExitPlanMode.set(requestId, { resolve });103this.emit('exit_plan_mode.requested', { requestId, ...data });104});105}106107respondToExitPlanMode(requestId: string, result: unknown) {108const pending = this._pendingExitPlanMode.get(requestId);109if (pending) {110pending.resolve(result);111this._pendingExitPlanMode.delete(requestId);112}113}114115respondToUserInput(requestId: string, response: unknown) {116const pending = this._pendingUserInputs.get(requestId);117if (pending) {118pending.resolve(response);119this._pendingUserInputs.delete(requestId);120}121}122123public lastSendOptions: { prompt: string; mode?: string; source?: string } | undefined;124public currentMode: string | undefined;125126async send(options: { prompt: string; mode?: string }) {127this.lastSendOptions = options;128// Simulate a normal successful turn with a message129this.emit('user.message', { content: options.prompt });130this.emit('assistant.turn_start', {});131this.emit('assistant.message', { messageId: `msg_${Date.now()}`, content: `Echo: ${options.prompt}` });132this.emit('assistant.turn_end', {});133}134135async compactHistory() { return { success: true }; }136137async abort() {138this.aborted = true;139}140141isAbortable(): boolean { return true; }142143async initializeAndValidateTools() { }144getCurrentToolMetadata(): unknown[] | undefined { return this._toolMetadata; }145private _toolMetadata: unknown[] | undefined;146set toolMetadata(value: unknown[] | undefined) { this._toolMetadata = value; }147148setAuthInfo(info: any) { this.authInfo = info; }149async getSelectedModel() { return this._selectedModel; }150async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; }151async getEvents() { return []; }152getPlanPath(): string | null { return null; }153154usage = {155getMetrics: async () => ({156lastCallInputTokens: 100,157lastCallOutputTokens: 50,158totalPremiumRequestCost: 0,159totalUserRequests: 1,160totalApiDurationMs: 1000,161sessionStartTime: Date.now(),162codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0 },163modelMetrics: {},164currentModel: this._selectedModel,165}),166};167}168169function createWorkspaceService(root: string): IWorkspaceService {170const rootUri = Uri.file(root);171return new class extends TestWorkspaceService {172override getWorkspaceFolders() {173return [174rootUri175];176}177override getWorkspaceFolder(uri: Uri) {178return uri.fsPath.startsWith(rootUri.fsPath) ? rootUri : undefined;179}180};181}182183function workspaceInfoFor(workingDirectory: Uri | undefined): IWorkspaceInfo {184return {185folder: workingDirectory,186repository: undefined,187worktree: undefined,188worktreeProperties: undefined,189};190}191192class UsageCapturingStream extends MockChatResponseStream {193public readonly usages: import('vscode').ChatResultUsage[] = [];194constructor() {195super();196}197override usage(u: import('vscode').ChatResultUsage): void {198this.usages.push(u);199}200}201202describe('CopilotCLISession', () => {203const disposables = new DisposableStore();204let sdkSession: MockSdkSession;205let workspaceService: IWorkspaceService;206let logger: ILogService;207let sessionWorkspaceInfo: IWorkspaceInfo;208let sessionAgentName: string | undefined;209let instaService: IInstantiationService;210let requestLogger: IRequestLogger;211let toolsService: FakeToolsService;212let configurationService: IConfigurationService;213let chatSessionMetadataStore: MockChatSessionMetadataStore;214let authInfo: NonNullable<SessionOptions['authInfo']>;215let userQuestionAnswer: IQuestionAnswer | undefined;216beforeEach(async () => {217const services = disposables.add(createExtensionUnitTestingServices());218const accessor = services.createTestingAccessor();219logger = accessor.get(ILogService);220requestLogger = new NullRequestLogger();221authInfo = {222type: 'token',223token: '',224host: 'https://github.com'225};226chatSessionMetadataStore = new MockChatSessionMetadataStore();227sdkSession = new MockSdkSession();228workspaceService = createWorkspaceService('/workspace');229sessionWorkspaceInfo = workspaceInfoFor(workspaceService.getWorkspaceFolders()![0]);230sessionAgentName = undefined;231configurationService = accessor.get(IConfigurationService);232await configurationService.setConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled, true);233instaService = services.seal();234toolsService = new FakeToolsService();235userQuestionAnswer = undefined;236});237238afterEach(() => {239vi.restoreAllMocks();240disposables.clear();241});242243244async function createSession(): Promise<CopilotCLISession> {245class FakeUserQuestionHandler implements IUserQuestionHandler {246_serviceBrand: undefined;247async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, toolCallId?: string): Promise<IQuestionAnswer | undefined> {248return userQuestionAnswer;249}250}251return disposables.add(new CopilotCLISession(252sessionWorkspaceInfo,253sessionAgentName,254sdkSession as unknown as Session,255[],256logger,257workspaceService,258chatSessionMetadataStore,259instaService,260requestLogger,261new NullICopilotCLIImageSupport(),262toolsService,263new FakeUserQuestionHandler(),264configurationService,265new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),266new MockGitService(),267{ _serviceBrand: undefined } as any268));269}270271it('handles a successful request and streams assistant output', async () => {272const session = await createSession();273const stream = new MockChatResponseStream();274275// Attach stream first, then invoke with new signature (no stream param)276session.attachStream(stream);277await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);278279expect(session.status).toBe(ChatSessionStatus.Completed);280expect(stream.output.join('\n')).toContain('Echo: Hello');281// Listeners are disposed after completion, so we only assert original streamed content.282});283284it('switches model when different modelId provided', async () => {285const session = await createSession();286const stream = new MockChatResponseStream();287session.attachStream(stream);288await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Hi' }, [], { model: 'modelB' }, authInfo, CancellationToken.None);289290expect(sdkSession._selectedModel).toBe('modelB');291});292293it('fails request when underlying send throws', async () => {294// Force send to throw295sdkSession.send = async () => { throw new Error('network'); };296const session = await createSession();297const stream = new MockChatResponseStream();298session.attachStream(stream);299await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Boom' }, [], undefined, authInfo, CancellationToken.None);300301expect(session.status).toBe(ChatSessionStatus.Failed);302expect(stream.output.join('\n')).toContain('Error: network');303});304305it('emits status events on successful request', async () => {306const session = await createSession();307const statuses: (ChatSessionStatus | undefined)[] = [];308const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s)));309const stream = new MockChatResponseStream();310session.attachStream(stream);311await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Status OK' }, [], { model: 'modelA' }, authInfo, CancellationToken.None);312listener.dispose?.();313314expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Completed]);315expect(session.status).toBe(ChatSessionStatus.Completed);316});317318it('emits status events on failed request', async () => {319// Force failure320sdkSession.send = async () => { throw new Error('boom'); };321const session = await createSession();322const statuses: (ChatSessionStatus | undefined)[] = [];323const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s)));324const stream = new MockChatResponseStream();325session.attachStream(stream);326await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Will Fail' }, [], undefined, authInfo, CancellationToken.None);327listener.dispose?.();328expect(stream.output.join('\n')).toContain('Error: boom');329});330331it('auto-approves read permission inside workspace without external handler', async () => {332let result: unknown;333sdkSession.send = async ({ prompt }: any) => {334sdkSession.emit('assistant.turn_start', {});335sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });336// Mid way through, make it look like the sdk requested permission while emitting other messages.337result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/workspace', 'file.ts'), intention: 'Read file' });338sdkSession.emit('assistant.turn_end', {});339};340const session = await createSession();341const stream = new MockChatResponseStream();342session.attachStream(stream);343344// Path must be absolute within workspace, should auto-approve345await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);346expect(result).toEqual({ kind: 'approve-once' });347});348349it('auto-approves read permission for files in session state directory', async () => {350let result: unknown;351const sessionFilePath = path.join('/mock-session-state', 'mock-session-id', 'plan.md');352sdkSession.send = async ({ prompt }: any) => {353sdkSession.emit('assistant.turn_start', {});354sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });355result = await sdkSession.emitPermissionRequest({ kind: 'read', path: sessionFilePath, intention: 'Read plan' });356sdkSession.emit('assistant.turn_end', {});357};358const session = await createSession();359const stream = new MockChatResponseStream();360session.attachStream(stream);361await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);362expect(result).toEqual({ kind: 'approve-once' });363});364365it('auto-approves write permission for files in session state directory', async () => {366let result: unknown;367const sessionFilePath = path.join('/mock-session-state', 'mock-session-id', 'plan.md');368sdkSession.send = async ({ prompt }: any) => {369sdkSession.emit('assistant.turn_start', {});370sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });371result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: sessionFilePath, intention: 'Write plan', diff: '', canOfferSessionApproval: false });372sdkSession.emit('assistant.turn_end', {});373};374const session = await createSession();375const stream = new MockChatResponseStream();376session.attachStream(stream);377await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);378expect(result).toEqual({ kind: 'approve-once' });379});380381it('auto-approves read permission for attached files outside workspace', async () => {382let result: unknown;383const attachedFilePath = '/outside-workspace/attached-file.ts';384sdkSession.send = async ({ prompt }: any) => {385sdkSession.emit('assistant.turn_start', {});386sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });387result = await sdkSession.emitPermissionRequest({ kind: 'read', path: attachedFilePath, intention: 'Read file' });388sdkSession.emit('assistant.turn_end', {});389};390const session = await createSession();391const stream = new MockChatResponseStream();392session.attachStream(stream);393394const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }];395await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None);396expect(result).toEqual({ kind: 'approve-once' });397});398399it('does not auto-approve read permission for non-attached files outside workspace', async () => {400let result: unknown;401const nonAttachedFilePath = '/outside-workspace/other-file.ts';402const attachedFilePath = '/outside-workspace/attached-file.ts';403toolsService.setConfirmationResult('no');404sdkSession.send = async ({ prompt }: any) => {405sdkSession.emit('assistant.turn_start', {});406sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });407result = await sdkSession.emitPermissionRequest({ kind: 'read', path: nonAttachedFilePath, intention: 'Read file' });408sdkSession.emit('assistant.turn_end', {});409};410const session = await createSession();411const stream = new MockChatResponseStream();412session.attachStream(stream);413414const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }];415await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None);416expect(result).toEqual({ kind: 'denied-interactively-by-user' });417expect(toolsService.invokeToolCalls).toHaveLength(2);418});419420it('auto-approves read permission inside working directory without external handler', async () => {421let result: unknown;422sessionWorkspaceInfo = workspaceInfoFor(URI.file('/workingDirectory'));423sdkSession.send = async ({ prompt }: any) => {424sdkSession.emit('assistant.turn_start', {});425sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });426// Mid way through, make it look like the sdk requested permission while emitting other messages.427result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/workingDirectory', 'file.ts'), intention: 'Read file' });428sdkSession.emit('assistant.turn_end', {});429};430const session = await createSession();431const stream = new MockChatResponseStream();432session.attachStream(stream);433434// Path must be absolute within workspace, should auto-approve435await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);436expect(result).toEqual({ kind: 'approve-once' });437});438439it('auto-approves read permission for files in workspace folder when worktree is the working directory', async () => {440let result: unknown;441const worktreeUri = URI.file('/worktrees/session1');442const folderUri = URI.file('/original-repo');443sessionWorkspaceInfo = {444folder: folderUri,445repository: folderUri,446worktree: worktreeUri,447worktreeProperties: { version: 1, autoCommit: false, baseCommit: 'abc', branchName: 'main', repositoryPath: '/original-repo', worktreePath: '/worktrees/session1' },448};449sdkSession.send = async ({ prompt }: any) => {450sdkSession.emit('assistant.turn_start', {});451sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });452// File is in workspace.folder (/original-repo), not in the worktree which is the working directory453result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/original-repo', 'src/main.ts'), intention: 'Read file' });454sdkSession.emit('assistant.turn_end', {});455};456const session = await createSession();457const stream = new MockChatResponseStream();458session.attachStream(stream);459460await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);461expect(result).toEqual({ kind: 'approve-once' });462});463464it('auto-approves read permission for files in the worktree when workspace has both worktree and repository', async () => {465let result: unknown;466const worktreeUri = URI.file('/worktrees/session1');467const folderUri = URI.file('/original-repo');468sessionWorkspaceInfo = {469folder: folderUri,470repository: folderUri,471worktree: worktreeUri,472worktreeProperties: { version: 1, autoCommit: false, baseCommit: 'abc', branchName: 'main', repositoryPath: '/original-repo', worktreePath: '/worktrees/session1' },473};474sdkSession.send = async ({ prompt }: any) => {475sdkSession.emit('assistant.turn_start', {});476sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });477// File is in the worktree which is also the working directory478result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/worktrees/session1', 'src/main.ts'), intention: 'Read file' });479sdkSession.emit('assistant.turn_end', {});480};481const session = await createSession();482const stream = new MockChatResponseStream();483session.attachStream(stream);484485await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);486expect(result).toEqual({ kind: 'approve-once' });487});488489it('requires read permission outside workspace and working directory', async () => {490let result: unknown;491toolsService.setConfirmationResult('no');492sdkSession.send = async ({ prompt }: any) => {493sdkSession.emit('assistant.turn_start', {});494sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });495// Mid way through, make it look like the sdk requested permission while emitting other messages.496result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/workingDirectory', 'file.ts'), intention: 'Read file' });497498sdkSession.emit('assistant.turn_end', {});499};500const session = await createSession();501const stream = new MockChatResponseStream();502session.attachStream(stream);503504// Path must be absolute within workspace, should auto-approve505await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);506expect(result).toEqual({ kind: 'denied-interactively-by-user' });507expect(toolsService.invokeToolCalls).toHaveLength(2);508expect(toolsService.invokeToolCalls[1].input).toMatchObject({509title: 'Read file(s)',510message: 'Read file'511});512});513514it('approves write permission when handler returns true', async () => {515let result: unknown;516const session = await createSession();517toolsService.setConfirmationResult('yes');518sdkSession.send = async ({ prompt }: any) => {519sdkSession.emit('assistant.turn_start', {});520sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });521// Mid way through, make it look like the sdk requested permission while emitting other messages.522result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: 'a.ts', intention: 'Update file', diff: '', canOfferSessionApproval: false });523sdkSession.emit('assistant.turn_end', {});524};525const stream = new MockChatResponseStream();526session.attachStream(stream);527528await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None);529530expect(result).toEqual({ kind: 'approve-once' });531});532533it('denies write permission when handler returns false', async () => {534let result: unknown;535const session = await createSession();536toolsService.setConfirmationResult('no');537sdkSession.send = async ({ prompt }: any) => {538sdkSession.emit('assistant.turn_start', {});539sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });540// Mid way through, make it look like the sdk requested permission while emitting other messages.541result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: 'b.ts', intention: 'Update file', diff: '', canOfferSessionApproval: false });542sdkSession.emit('assistant.turn_end', {});543};544const stream = new MockChatResponseStream();545session.attachStream(stream);546await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None);547548expect(result).toEqual({ kind: 'denied-interactively-by-user' });549});550551it('denies write permission when handler throws', async () => {552let result: unknown;553const session = await createSession();554toolsService.invokeTool = vi.fn(async () => {555throw new Error('oops');556});557sdkSession.send = async ({ prompt }: any) => {558sdkSession.emit('assistant.turn_start', {});559sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });560// Mid way through, make it look like the sdk requested permission while emitting other messages.561result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: 'err.ts', intention: 'Update file', diff: '', canOfferSessionApproval: false });562sdkSession.emit('assistant.turn_end', {});563};564const stream = new MockChatResponseStream();565session.attachStream(stream);566await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None);567568expect(result).toEqual({ kind: 'denied-interactively-by-user' });569});570571it('preserves order of edit toolCallIds and permissions for multiple pending edits', async () => {572// Arrange a deferred send so we can emit tool events before request finishes573let resolveSend: () => void;574sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });575const session = await createSession();576toolsService.setConfirmationResult('yes');577const stream = new MockChatResponseStream();578session.attachStream(stream);579// Spy on trackEdit to capture ordering (we don't want to depend on externalEdit mechanics here)580const trackedOrder: string[] = [];581const trackSpy = vi.spyOn(ExternalEditTracker.prototype, 'trackEdit').mockImplementation(async function (this: any, editKey: string) {582trackedOrder.push(editKey);583// Immediately resolve to avoid hanging on externalEdit lifecycle584return Promise.resolve();585});586587// Act: start handling request (do not await yet)588const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Edits' }, [], undefined, authInfo, CancellationToken.None);589590// Wait a tick to ensure event listeners are registered inside handleRequest591await new Promise(r => setTimeout(r, 0));592593// Emit 10 edit tool start events in rapid succession for the same file594const filePath = '/workspace/abc.py';595for (let i = 1; i <= 10; i++) {596const editToolCall: ToolCall = {597toolName: 'edit',598toolCallId: String(i),599arguments: { path: filePath, new_str: 'new content' },600};601sdkSession.emit('tool.execution_start', editToolCall);602}603604// Now request permissions sequentially AFTER all tool calls have been emitted605const permissionResults: any[] = [];606for (let i = 1; i <= 10; i++) {607// Each permission request should dequeue the next toolCallId for the file608const result = await sdkSession.emitPermissionRequest({609kind: 'write',610fileName: filePath,611intention: 'Apply edit',612diff: '',613toolCallId: String(i),614canOfferSessionApproval: false615});616permissionResults.push(result);617// Complete the edit so the tracker (if it were real) would finish; emit completion event618sdkSession.emit('tool.execution_complete', {619toolCallId: String(i),620toolName: 'str_replace_editor',621arguments: { command: 'str_replace', path: filePath },622success: true,623result: { content: '' }624});625}626627// Allow the request to finish628resolveSend!();629await requestPromise;630631// Assert ordering of trackEdit invocations exactly matches toolCallIds 1..10632expect(trackedOrder).toEqual(Array.from({ length: 10 }, (_, i) => String(i + 1)));633expect(permissionResults.every(r => r.kind === 'approve-once')).toBe(true);634expect(trackSpy).toHaveBeenCalledTimes(10);635636trackSpy.mockRestore();637});638639it('delays tool invocation messages for permission-requiring tools until permission is resolved', async () => {640let resolveSend: () => void;641sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });642const session = await createSession();643const pushedParts: unknown[] = [];644const stream = new MockChatResponseStream(part => pushedParts.push(part));645session.attachStream(stream);646toolsService.setConfirmationResult('yes');647648const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run bash' }, [], undefined, authInfo, CancellationToken.None);649await new Promise(r => setTimeout(r, 0));650651// Emit a bash tool start - this should be delayed652const bashToolCall: ToolCall = { toolName: 'bash', toolCallId: 'bash-delay-1', arguments: { command: 'echo hi', description: 'Echo test' } };653sdkSession.emit('tool.execution_start', bashToolCall);654await new Promise(r => setTimeout(r, 0));655656// No ChatToolInvocationPart should be pushed yet for the bash tool657const toolPartsBeforePermission = pushedParts.filter(p => p instanceof ChatToolInvocationPart);658expect(toolPartsBeforePermission).toHaveLength(0);659660// When permission is requested, the pending messages should be flushed661await sdkSession.emitPermissionRequest({662kind: 'shell',663commands: [{ identifier: 'echo hi', readOnly: false }],664intention: 'Run command',665fullCommandText: 'echo hi',666possiblePaths: [],667possibleUrls: [],668hasWriteFileRedirection: false,669canOfferSessionApproval: false670});671await new Promise(r => setTimeout(r, 0));672673const toolPartsAfterPermission = pushedParts.filter(p => p instanceof ChatToolInvocationPart);674expect(toolPartsAfterPermission.length).toBeGreaterThanOrEqual(1);675676sdkSession.emit('tool.execution_complete', { toolCallId: 'bash-delay-1', toolName: 'bash', success: true, result: { content: 'hi' } });677resolveSend!();678await requestPromise;679});680681it('uses remote permission responses when Mission Control is active', async () => {682let permissionResult: unknown;683sdkSession.send = async () => {684permissionResult = await sdkSession.emitPermissionRequest({685kind: 'shell',686toolCallId: 'remote-permission-tool',687commands: [{ identifier: 'echo "Hello world"', readOnly: false }],688intention: 'Run command',689fullCommandText: 'echo "Hello world"',690possiblePaths: [],691possibleUrls: [],692hasWriteFileRedirection: false,693canOfferSessionApproval: false694});695};696const session = await createSession();697let localPromptToken: CancellationToken | undefined;698const invokeToolSpy = vi.spyOn(toolsService, 'invokeTool').mockImplementation((async (name: string, options: unknown, token?: CancellationToken) => {699if (name === 'vscode_get_confirmation' || name === 'vscode_get_terminal_confirmation') {700localPromptToken = token;701return await new Promise(resolve => {702token?.onCancellationRequested(() => resolve({ content: [new LanguageModelTextPart('no')] }));703});704}705return { content: [] };706}) as typeof toolsService.invokeTool);707const remoteState = {708mcSessionId: 'mc-session',709mcEventBuffer: [],710mcCompletedCommandIds: [],711mcPendingPermissionRequests: new Map(),712mcFlushInterval: undefined,713mcPollInterval: undefined,714mcLastEventId: null,715mcLastSubmitAttemptTimeMs: Date.now(),716mcProcessedCommandIds: new Set<string>(),717mcSdkSession: sdkSession as unknown as Session,718mcEventListenerDispose: undefined,719mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,720};721Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });722723const requestPromise = session.handleRequest(724{ id: '', toolInvocationToken: undefined as never },725{ prompt: 'Run bash' },726[],727undefined,728authInfo,729CancellationToken.None730);731await new Promise(r => setTimeout(r, 0));732733await (CopilotCLISession as any)._pollMcCommandsStatic(734session.sessionId,735remoteState,736{737getPendingCommands: async () => [{738id: 'mc-command-1',739content: JSON.stringify({ promptId: 'remote-permission-tool', approved: true, scope: 'once' }),740state: 'in_progress',741type: 'permission_response',742}],743},744logger,745);746747await requestPromise;748749expect(permissionResult).toEqual({ kind: 'approve-once' });750const confirmationToolCalls = invokeToolSpy.mock.calls.filter(call =>751call[0] === 'vscode_get_confirmation' || call[0] === 'vscode_get_terminal_confirmation'752);753expect(confirmationToolCalls).toHaveLength(1);754expect(localPromptToken?.isCancellationRequested).toBe(true);755expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-1']);756});757758it('uses local permission responses when Mission Control is active', async () => {759let permissionResult: unknown;760sdkSession.send = async () => {761permissionResult = await sdkSession.emitPermissionRequest({762kind: 'shell',763toolCallId: 'local-permission-tool',764commands: [{ identifier: 'echo "Hello world"', readOnly: false }],765intention: 'Run command',766fullCommandText: 'echo "Hello world"',767possiblePaths: [],768possibleUrls: [],769hasWriteFileRedirection: false,770canOfferSessionApproval: false771});772};773toolsService.setConfirmationResult('yes');774const session = await createSession();775const invokeToolSpy = vi.spyOn(toolsService, 'invokeTool');776const remoteState = {777mcSessionId: 'mc-session',778mcEventBuffer: [],779mcCompletedCommandIds: [],780mcPendingPermissionRequests: new Map(),781mcFlushInterval: undefined,782mcPollInterval: undefined,783mcLastEventId: null,784mcLastSubmitAttemptTimeMs: Date.now(),785mcProcessedCommandIds: new Set<string>(),786mcSdkSession: sdkSession as unknown as Session,787mcEventListenerDispose: undefined,788mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,789};790Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });791792await session.handleRequest(793{ id: '', toolInvocationToken: undefined as never },794{ prompt: 'Run bash' },795[],796undefined,797authInfo,798CancellationToken.None799);800801expect(permissionResult).toEqual({ kind: 'approve-once' });802const confirmationToolCalls = invokeToolSpy.mock.calls.filter(call =>803call[0] === 'vscode_get_confirmation' || call[0] === 'vscode_get_terminal_confirmation'804);805expect(confirmationToolCalls).toHaveLength(1);806expect(remoteState.mcPendingPermissionRequests.size).toBe(0);807});808809it('uses remote ask user responses when Mission Control is active', async () => {810let userInputResult: unknown;811const notifiedAnswers: Array<{ toolCallId: string; question: IQuestion; response: UserInputResponse }> = [];812sdkSession.send = async () => {813userInputResult = await sdkSession.emitUserInputRequest({814question: 'What is your favorite VS Code feature or extension?',815allowFreeform: true,816toolCallId: 'ask-user-tool',817});818};819const session = await createSession();820let localPromptToken: CancellationToken | undefined;821Object.defineProperty(session, '_userQuestionHandler', {822value: {823_serviceBrand: undefined,824async askUserQuestion(_question: IQuestion, _toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, _toolCallId?: string): Promise<IQuestionAnswer | undefined> {825localPromptToken = token;826return await new Promise<IQuestionAnswer | undefined>(resolve => {827token.onCancellationRequested(() => resolve(undefined));828});829},830async notifyQuestionCarouselAnswer(toolCallId: string, question: IQuestion, response: UserInputResponse): Promise<void> {831notifiedAnswers.push({ toolCallId, question, response });832},833} satisfies IUserQuestionHandler,834configurable: true,835});836const remoteState = {837mcSessionId: 'mc-session',838mcEventBuffer: [],839mcCompletedCommandIds: [],840mcPendingPermissionRequests: new Map(),841mcFlushInterval: undefined,842mcPollInterval: undefined,843mcLastEventId: null,844mcLastSubmitAttemptTimeMs: Date.now(),845mcProcessedCommandIds: new Set<string>(),846mcSdkSession: sdkSession as unknown as Session,847mcEventListenerDispose: undefined,848mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,849};850Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });851852const requestPromise = session.handleRequest(853{ id: '', toolInvocationToken: {} as never },854{ prompt: 'Ask me about VS Code' },855[],856undefined,857authInfo,858CancellationToken.None859);860await new Promise(r => setTimeout(r, 0));861862await (CopilotCLISession as any)._pollMcCommandsStatic(863session.sessionId,864remoteState,865{866getPendingCommands: async () => [{867id: 'mc-command-ask-user',868content: JSON.stringify({ requestId: 'user-input-1', answer: 'none', wasFreeform: true }),869state: 'in_progress',870type: 'ask_user_response',871}],872},873logger,874);875876await requestPromise;877878expect(userInputResult).toEqual({ answer: 'none', wasFreeform: true });879expect(notifiedAnswers).toEqual([{880toolCallId: 'ask-user-tool',881question: {882question: 'What is your favorite VS Code feature or extension?',883options: [],884allowFreeformInput: true,885header: 'What is your favorite VS Code feature or extension?',886},887response: { answer: 'none', wasFreeform: true },888}]);889expect(localPromptToken?.isCancellationRequested).toBe(true);890expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-ask-user']);891});892893it('aborts pending remote ask user requests when Mission Control stop is requested', async () => {894let userInputResult: unknown;895sdkSession.send = async () => {896userInputResult = await sdkSession.emitUserInputRequest({897question: 'What is your favorite VS Code feature or extension?',898allowFreeform: true,899toolCallId: 'ask-user-tool',900});901if (sdkSession.aborted) {902return;903}904sdkSession.emit('assistant.turn_start', {});905sdkSession.emit('assistant.turn_end', {});906};907const session = await createSession();908let localPromptToken: CancellationToken | undefined;909Object.defineProperty(session, '_userQuestionHandler', {910value: {911_serviceBrand: undefined,912async askUserQuestion(_question: IQuestion, _toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise<IQuestionAnswer | undefined> {913localPromptToken = token;914return await new Promise<IQuestionAnswer | undefined>(resolve => {915token.onCancellationRequested(() => resolve(undefined));916});917},918} satisfies IUserQuestionHandler,919configurable: true,920});921const remoteState = {922mcSessionId: 'mc-session',923mcEventBuffer: [],924mcCompletedCommandIds: [],925mcPendingPermissionRequests: new Map(),926mcFlushInterval: undefined,927mcPollInterval: undefined,928mcLastEventId: null,929mcLastSubmitAttemptTimeMs: Date.now(),930mcProcessedCommandIds: new Set<string>(),931mcSdkSession: sdkSession as unknown as Session,932mcEventListenerDispose: undefined,933mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,934};935Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });936937const requestPromise = session.handleRequest(938{ id: '', toolInvocationToken: {} as never },939{ prompt: 'Ask me about VS Code' },940[],941undefined,942authInfo,943CancellationToken.None944);945await new Promise(r => setTimeout(r, 0));946947await (CopilotCLISession as any)._pollMcCommandsStatic(948session.sessionId,949remoteState,950{951getPendingCommands: async () => [{952id: 'mc-command-abort',953content: '',954state: 'in_progress',955type: 'abort',956}],957},958logger,959);960961await requestPromise;962963expect(sdkSession.aborted).toBe(true);964expect(userInputResult).toEqual({ answer: '', wasFreeform: false });965expect(localPromptToken?.isCancellationRequested).toBe(true);966expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-abort']);967});968969it('reports remote control status when /remote is invoked without arguments', async () => {970await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true);971const session = await createSession();972const stream = new MockChatResponseStream();973session.attachStream(stream);974975await session.handleRequest(976{ id: '', toolInvocationToken: undefined as never },977{ command: 'remote', prompt: '' },978[],979undefined,980authInfo,981CancellationToken.None982);983984expect(stream.output.join('\n')).toContain('Remote control is disabled. Use /remote on to enable it.');985});986987it('reports enabled remote control status when /remote is invoked without arguments', async () => {988await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true);989const session = await createSession();990const stream = new MockChatResponseStream();991session.attachStream(stream);992const remoteState = {993mcSessionId: 'mc-session',994mcFrontendUrl: 'https://github.com/microsoft/vscode/tasks/123',995mcEventBuffer: [],996mcCompletedCommandIds: [],997mcPendingPermissionRequests: new Map(),998mcFlushInterval: undefined,999mcPollInterval: undefined,1000mcLastEventId: null,1001mcLastSubmitAttemptTimeMs: Date.now(),1002mcProcessedCommandIds: new Set<string>(),1003mcSdkSession: sdkSession as unknown as Session,1004mcEventListenerDispose: undefined,1005mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1006};1007Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });10081009await session.handleRequest(1010{ id: '', toolInvocationToken: undefined as never },1011{ command: 'remote', prompt: '' },1012[],1013undefined,1014authInfo,1015CancellationToken.None1016);10171018expect(stream.output.join('\n')).toContain('Remote control is enabled. Use /remote off to disable it. Session URL: https://github.com/microsoft/vscode/tasks/123');1019});10201021it('shows /remote usage for unsupported arguments', async () => {1022await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true);1023const session = await createSession();1024const stream = new MockChatResponseStream();1025session.attachStream(stream);10261027await session.handleRequest(1028{ id: '', toolInvocationToken: undefined as never },1029{ command: 'remote', prompt: 'wat' },1030[],1031undefined,1032authInfo,1033CancellationToken.None1034);10351036expect(stream.output.join('\n')).toContain('Usage: /remote, /remote on, /remote off');1037});10381039it('forwards session.idle to Mission Control so remote running state clears', async () => {1040const session = await createSession();1041const remoteState = {1042mcSessionId: 'mc-session',1043mcEventBuffer: [],1044mcCompletedCommandIds: [],1045mcPendingPermissionRequests: new Map(),1046mcFlushInterval: undefined,1047mcPollInterval: undefined,1048mcLastEventId: null,1049mcLastSubmitAttemptTimeMs: Date.now(),1050mcProcessedCommandIds: new Set<string>(),1051mcSdkSession: sdkSession as unknown as Session,1052mcEventListenerDispose: undefined,1053mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1054};1055Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });10561057(session as any)._bufferMcEvent({ type: 'session.idle', data: {} });10581059expect(remoteState.mcEventBuffer).toHaveLength(1);1060expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('session.idle');1061});10621063it('forwards session.title_changed to Mission Control as an ephemeral event', async () => {1064const session = await createSession();1065const remoteState = {1066mcSessionId: 'mc-session',1067mcEventBuffer: [],1068mcCompletedCommandIds: [],1069mcPendingPermissionRequests: new Map(),1070mcFlushInterval: undefined,1071mcPollInterval: undefined,1072mcLastEventId: null,1073mcLastSubmitAttemptTimeMs: Date.now(),1074mcProcessedCommandIds: new Set<string>(),1075mcSdkSession: sdkSession as unknown as Session,1076mcEventListenerDispose: undefined,1077mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1078};1079Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });10801081(session as any)._bufferMcEvent({1082type: 'session.title_changed',1083id: 'title-change-1',1084timestamp: '2026-01-01T00:00:00.000Z',1085parentId: 'visible-root-message',1086ephemeral: true,1087data: { title: 'Remote Session Title' },1088});10891090expect(remoteState.mcEventBuffer).toHaveLength(1);1091expect((remoteState.mcEventBuffer[0] as { type: string; ephemeral?: true }).type).toBe('session.title_changed');1092expect((remoteState.mcEventBuffer[0] as { ephemeral?: true }).ephemeral).toBe(true);1093expect((remoteState.mcEventBuffer[0] as { data: { title: string } }).data.title).toBe('Remote Session Title');1094});10951096it('prefers existing session history over the current /remote prompt when deriving the Mission Control title', async () => {1097const session = await createSession();1098vi.spyOn(sdkSession, 'getEvents').mockReturnValue([1099{ type: 'user.message', data: { content: 'hey' } },1100] as any);1101(session as any)._pendingPrompt = '/remote';11021103await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('hey');1104});11051106it('sanitizes hidden prompt markup when deriving the Mission Control title', async () => {1107const session = await createSession();1108vi.spyOn(sdkSession, 'getEvents').mockReturnValue([1109{1110type: 'user.message',1111data: {1112content: '/remote <reminder>IMPORTANT: hidden context</reminder><attachments><attachment id="microsoft/vscode-tools">repo</attachment></attachments><userRequest></userRequest>',1113}1114},1115] as any);11161117await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('/remote');1118});11191120it('sanitizes hidden prompt markup before forwarding user messages to Mission Control', async () => {1121const session = await createSession();1122const remoteState = {1123mcSessionId: 'mc-session',1124mcEventBuffer: [],1125mcCompletedCommandIds: [],1126mcPendingPermissionRequests: new Map(),1127mcFlushInterval: undefined,1128mcPollInterval: undefined,1129mcLastEventId: null,1130mcLastSubmitAttemptTimeMs: Date.now(),1131mcProcessedCommandIds: new Set<string>(),1132mcSdkSession: sdkSession as unknown as Session,1133mcEventListenerDispose: undefined,1134mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1135};1136Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });11371138(session as any)._bufferMcEvent({1139type: 'user.message',1140id: 'remote-command-message',1141timestamp: '2026-01-01T00:00:00.000Z',1142data: {1143content: '/remote <reminder>IMPORTANT: hidden context</reminder><attachments><attachment id="microsoft/vscode-tools">repo</attachment></attachments><userRequest></userRequest>',1144},1145});11461147expect(remoteState.mcEventBuffer).toHaveLength(1);1148expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('/remote');1149});11501151it('strips shell tool descriptions before forwarding tool starts to Mission Control', async () => {1152const session = await createSession();1153const remoteState = {1154mcSessionId: 'mc-session',1155mcEventBuffer: [],1156mcCompletedCommandIds: [],1157mcPendingPermissionRequests: new Map(),1158mcFlushInterval: undefined,1159mcPollInterval: undefined,1160mcLastEventId: null,1161mcLastSubmitAttemptTimeMs: Date.now(),1162mcProcessedCommandIds: new Set<string>(),1163mcSdkSession: sdkSession as unknown as Session,1164mcEventListenerDispose: undefined,1165mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1166};1167Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });11681169(session as any)._bufferMcEvent({1170type: 'tool.execution_start',1171data: {1172toolCallId: 'bash-1',1173toolName: 'bash',1174arguments: { command: 'echo hello', description: 'Simple echo command.' },1175},1176});11771178expect(remoteState.mcEventBuffer).toHaveLength(1);1179expect((remoteState.mcEventBuffer[0] as {1180data: { arguments: { command: string; description?: string } };1181}).data.arguments).toEqual({ command: 'echo hello' });1182});11831184it('strips task descriptions before forwarding tool starts to Mission Control', async () => {1185const session = await createSession();1186const remoteState = {1187mcSessionId: 'mc-session',1188mcEventBuffer: [],1189mcCompletedCommandIds: [],1190mcPendingPermissionRequests: new Map(),1191mcFlushInterval: undefined,1192mcPollInterval: undefined,1193mcLastEventId: null,1194mcLastSubmitAttemptTimeMs: Date.now(),1195mcProcessedCommandIds: new Set<string>(),1196mcSdkSession: sdkSession as unknown as Session,1197mcEventListenerDispose: undefined,1198mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1199};1200Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });12011202(session as any)._bufferMcEvent({1203type: 'tool.execution_start',1204data: {1205toolCallId: 'task-1',1206toolName: 'task',1207arguments: { description: 'Simple task.', prompt: 'Run echo', agent_type: 'task' },1208},1209});12101211expect(remoteState.mcEventBuffer).toHaveLength(1);1212expect((remoteState.mcEventBuffer[0] as {1213data: { arguments: { prompt: string; agent_type: string; description?: string } };1214}).data.arguments).toEqual({ prompt: 'Run echo', agent_type: 'task' });1215});12161217it('does not forward report_intent tool events to Mission Control', async () => {1218const session = await createSession();1219const remoteState = {1220mcSessionId: 'mc-session',1221mcEventBuffer: [],1222mcCompletedCommandIds: [],1223mcPendingPermissionRequests: new Map(),1224mcFlushInterval: undefined,1225mcPollInterval: undefined,1226mcLastEventId: null,1227mcLastSubmitAttemptTimeMs: Date.now(),1228mcProcessedCommandIds: new Set<string>(),1229mcSdkSession: sdkSession as unknown as Session,1230mcEventListenerDispose: undefined,1231mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1232};1233Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });12341235(session as any)._bufferMcEvent({1236type: 'tool.execution_start',1237data: { toolCallId: 'ri-1', toolName: 'report_intent', arguments: { intent: 'Running echo command' } },1238});1239(session as any)._bufferMcEvent({1240type: 'tool.execution_complete',1241data: { toolCallId: 'ri-1', toolName: 'report_intent', success: true },1242});1243(session as any)._bufferMcEvent({1244type: 'tool.execution_start',1245data: { toolCallId: 'bash-1', toolName: 'bash', arguments: { command: 'echo hello' } },1246});12471248expect(remoteState.mcEventBuffer).toHaveLength(1);1249expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('tool.execution_start');1250expect((remoteState.mcEventBuffer[0] as { data: { toolName: string } }).data.toolName).toBe('bash');1251});12521253it('forwards command-sourced user messages and acknowledges the command with the echoed turn', async () => {1254const session = await createSession();1255const remoteState = {1256mcSessionId: 'mc-session',1257mcEventBuffer: [],1258mcCompletedCommandIds: [],1259mcPendingPermissionRequests: new Map(),1260mcFlushInterval: undefined,1261mcPollInterval: undefined,1262mcLastEventId: null,1263mcLastSubmitAttemptTimeMs: Date.now(),1264mcProcessedCommandIds: new Set<string>(),1265mcPendingCommandCompletionIds: new Set<string>(['mc-command-1']),1266mcSdkSession: sdkSession as unknown as Session,1267mcEventListenerDispose: undefined,1268mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1269};1270Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });12711272(session as any)._bufferMcEvent({1273type: 'user.message',1274id: 'remote-command-message',1275timestamp: '2026-01-01T00:00:00.000Z',1276parentId: 'visible-root-message',1277data: { content: 'hey', source: 'command-mc-command-1' },1278});1279expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-1']);12801281(session as any)._bufferMcEvent({1282type: 'assistant.message',1283id: 'assistant-reply',1284timestamp: '2026-01-01T00:00:01.000Z',1285parentId: 'remote-command-message',1286data: { content: 'Hello! How can I help you today?' },1287});12881289expect(remoteState.mcEventBuffer).toHaveLength(2);1290expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('user.message');1291expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('hey');1292expect((remoteState.mcEventBuffer[1] as { type: string; parentId: string | null }).type).toBe('assistant.message');1293expect((remoteState.mcEventBuffer[1] as { parentId: string | null }).parentId).toBe('remote-command-message');1294});12951296it('forwards remote command source to the SDK send options', async () => {1297const session = await createSession();1298const stream = new MockChatResponseStream();1299session.attachStream(stream);13001301await session.handleRequest(1302{ id: '', toolInvocationToken: undefined as never },1303{ prompt: 'hey', source: 'command-mc-command-1' },1304[],1305undefined,1306authInfo,1307CancellationToken.None1308);13091310expect(sdkSession.lastSendOptions?.source).toBe('command-mc-command-1');1311});13121313it('flushes completed Mission Control command ids even when there are no buffered events', async () => {1314const session = await createSession();1315const submitEvents = vi.fn(async () => true);1316const remoteState = {1317mcSessionId: 'mc-session',1318mcEventBuffer: [],1319mcCompletedCommandIds: ['mc-command-1'],1320mcPendingPermissionRequests: new Map(),1321mcFlushInterval: undefined,1322mcPollInterval: undefined,1323mcLastEventId: null,1324mcLastSubmitAttemptTimeMs: Date.now(),1325mcProcessedCommandIds: new Set<string>(),1326mcSdkSession: sdkSession as unknown as Session,1327mcEventListenerDispose: undefined,1328mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1329};1330Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });1331Object.defineProperty(session, '_missionControlApiClient', {1332value: { submitEvents },1333configurable: true,1334});13351336await (session as any)._flushMcEvents();13371338expect(submitEvents).toHaveBeenCalledWith('mc-session', [], ['mc-command-1']);1339expect(remoteState.mcCompletedCommandIds).toEqual([]);1340});13411342it('announces remote control disabled to Mission Control before detaching locally', async () => {1343const session = await createSession();1344const submitEvents = vi.fn(async () => true);1345const deleteSession = vi.fn(async () => undefined);1346const pendingRequest = vi.fn();1347const mcEventListenerDispose = vi.fn();1348const remoteState = {1349mcSessionId: 'mc-session',1350mcEventBuffer: [],1351mcCompletedCommandIds: [],1352mcPendingPermissionRequests: new Map([['prompt-1', { resolve: pendingRequest }]]),1353mcFlushInterval: undefined,1354mcPollInterval: undefined,1355mcLastEventId: null,1356mcLastSubmitAttemptTimeMs: Date.now(),1357mcProcessedCommandIds: new Set<string>(),1358mcSdkSession: sdkSession as unknown as Session,1359mcEventListenerDispose,1360mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,1361};1362Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });1363Object.defineProperty(session, '_missionControlApiClient', {1364value: { submitEvents, deleteSession },1365configurable: true,1366});13671368await (session as any)._teardownRemoteControl();13691370expect(pendingRequest).toHaveBeenCalledWith({ kind: 'denied-interactively-by-user' });1371expect(mcEventListenerDispose).toHaveBeenCalledTimes(1);1372expect(submitEvents).toHaveBeenCalledWith(1373'mc-session',1374expect.arrayContaining([1375expect.objectContaining({ type: 'session.remote_steerable_changed', data: { remoteSteerable: false } }),1376expect.objectContaining({ type: 'session.idle', data: {} }),1377]),1378[],1379);1380expect(deleteSession).not.toHaveBeenCalled();1381});13821383it('immediately pushes invocation messages for non-permission-requiring tools like MCP', async () => {1384let resolveSend: () => void;1385sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });1386const session = await createSession();1387const pushedParts: unknown[] = [];1388const stream = new MockChatResponseStream(part => pushedParts.push(part));1389session.attachStream(stream);13901391const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run MCP tool' }, [], undefined, authInfo, CancellationToken.None);1392await new Promise(r => setTimeout(r, 0));13931394// Emit an MCP tool start - this should NOT be delayed1395sdkSession.emit('tool.execution_start', { toolName: 'my_mcp_tool', toolCallId: 'mcp-nodelay-1', mcpServerName: 'test-server', mcpToolName: 'my-tool', arguments: { foo: 'bar' } });1396await new Promise(r => setTimeout(r, 0));13971398const toolParts = pushedParts.filter(p => p instanceof ChatToolInvocationPart);1399expect(toolParts.length).toBeGreaterThanOrEqual(1);14001401sdkSession.emit('tool.execution_complete', { toolCallId: 'mcp-nodelay-1', toolName: 'my_mcp_tool', mcpServerName: 'test-server', mcpToolName: 'my-tool', success: true, result: { contents: [] } });1402resolveSend!();1403await requestPromise;1404});14051406it('flushes delayed invocation messages when assistant message arrives', async () => {1407let resolveSend: () => void;1408sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });1409const session = await createSession();1410const pushedParts: unknown[] = [];1411const stream = new MockChatResponseStream(part => pushedParts.push(part));1412session.attachStream(stream);14131414const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test flush' }, [], undefined, authInfo, CancellationToken.None);1415await new Promise(r => setTimeout(r, 0));14161417// Emit a bash tool start (delayed)1418sdkSession.emit('tool.execution_start', { toolName: 'bash', toolCallId: 'bash-flush-1', arguments: { command: 'ls', description: 'List' } });1419await new Promise(r => setTimeout(r, 0));14201421expect(pushedParts.filter(p => p instanceof ChatToolInvocationPart)).toHaveLength(0);14221423// Emit an assistant message delta - should flush1424sdkSession.emit('assistant.message_delta', { deltaContent: 'Hello', messageId: 'msg-1' });1425await new Promise(r => setTimeout(r, 0));14261427expect(pushedParts.filter(p => p instanceof ChatToolInvocationPart).length).toBeGreaterThanOrEqual(1);14281429sdkSession.emit('tool.execution_complete', { toolCallId: 'bash-flush-1', toolName: 'bash', success: true, result: { content: '' } });1430resolveSend!();1431await requestPromise;1432});14331434describe('/compact command', () => {1435it('compacts the conversation and reports success', async () => {1436const session = await createSession();1437const stream = new MockChatResponseStream();1438session.attachStream(stream);14391440await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { command: 'compact', prompt: '' }, [], undefined, authInfo, CancellationToken.None);14411442expect(sdkSession.currentMode).toBe('interactive');1443expect(stream.output.join('\n')).toContain('Compacted conversation.');1444});1445});14461447describe('steering (sending messages to a busy session)', () => {1448it('allows steering after an earlier failed request', async () => {1449sdkSession.send = async () => {1450throw new Error('boom');1451};14521453const session = await createSession();1454const stream = new MockChatResponseStream();1455session.attachStream(stream);14561457await session.handleRequest(1458{ id: 'req-1', toolInvocationToken: undefined as never },1459{ prompt: 'Initial failure' }, [], undefined, authInfo, CancellationToken.None1460);1461expect(session.status).toBe(ChatSessionStatus.Failed);14621463let resolveSecondSend!: () => void;1464let sendCallCount = 0;1465sdkSession.send = async (options: any) => {1466sendCallCount++;1467sdkSession.lastSendOptions = options;1468if (sendCallCount === 1) {1469await new Promise<void>(r => { resolveSecondSend = r; });1470}1471sdkSession.emit('assistant.turn_start', {});1472sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1473sdkSession.emit('assistant.turn_end', {});1474};14751476const secondRequest = session.handleRequest(1477{ id: 'req-2', toolInvocationToken: undefined as never },1478{ prompt: 'Second request' }, [], undefined, authInfo, CancellationToken.None1479);1480await new Promise(r => setTimeout(r, 10));14811482const steeringRequest = session.handleRequest(1483{ id: 'req-3', toolInvocationToken: undefined as never },1484{ prompt: 'Steer after failure' }, [], undefined, authInfo, CancellationToken.None1485);1486await new Promise(r => setTimeout(r, 10));14871488expect(sdkSession.lastSendOptions?.mode).toBe('immediate');1489expect(sdkSession.lastSendOptions?.prompt).toBe('Steer after failure');14901491resolveSecondSend();1492await Promise.all([secondRequest, steeringRequest]);1493expect(session.status).toBe(ChatSessionStatus.Completed);1494});14951496it('routes through steering when session is already InProgress', async () => {1497// Arrange: make `send` block so the first request stays in progress1498let resolveFirstSend: () => void = () => { };1499let sendCallCount = 0;1500sdkSession.send = async (options: any) => {1501sendCallCount++;1502sdkSession.lastSendOptions = options;1503if (sendCallCount === 1) {1504// First request blocks until we resolve1505await new Promise<void>(r => { resolveFirstSend = r; });1506}1507sdkSession.emit('assistant.turn_start', {});1508sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1509sdkSession.emit('assistant.turn_end', {});1510};15111512const session = await createSession();1513const stream = new MockChatResponseStream();1514session.attachStream(stream);15151516// Act: start first request (will block in send)1517const firstRequest = session.handleRequest(1518{ id: 'req-1', toolInvocationToken: undefined as never },1519{ prompt: 'First prompt' }, [], undefined, authInfo, CancellationToken.None1520);1521await new Promise(r => setTimeout(r, 10));15221523// Session should be InProgress1524expect(session.status).toBe(ChatSessionStatus.InProgress);15251526// Send a steering request while first is still running1527const steeringRequest = session.handleRequest(1528{ id: 'req-2', toolInvocationToken: undefined as never },1529{ prompt: 'Steer this' }, [], undefined, authInfo, CancellationToken.None1530);1531await new Promise(r => setTimeout(r, 10));15321533// The steering send should have been called with mode: 'immediate'1534expect(sdkSession.lastSendOptions?.mode).toBe('immediate');1535expect(sdkSession.lastSendOptions?.prompt).toBe('Steer this');15361537// Unblock the first request1538resolveFirstSend();1539await Promise.all([firstRequest, steeringRequest]);15401541expect(session.status).toBe(ChatSessionStatus.Completed);1542});15431544it('does not set mode to immediate for the first (non-steering) request', async () => {1545const session = await createSession();1546const stream = new MockChatResponseStream();1547session.attachStream(stream);15481549await session.handleRequest(1550{ id: 'req-1', toolInvocationToken: undefined as never },1551{ prompt: 'Normal prompt' }, [], undefined, authInfo, CancellationToken.None1552);15531554expect(sdkSession.lastSendOptions?.mode).toBeUndefined();1555expect(sdkSession.lastSendOptions?.prompt).toBe('Normal prompt');1556});15571558it('accumulates attachments across steering requests for permission auto-approval', async () => {1559let resolveFirstSend!: () => void;1560let sendCallCount = 0;1561let permissionResult: unknown;15621563// The attached file path is outside workspace1564const attachedFilePath = '/outside-workspace/steering-file.ts';15651566sdkSession.send = async (options: any) => {1567sendCallCount++;1568const thisCallNumber = sendCallCount;1569sdkSession.lastSendOptions = options;1570if (thisCallNumber === 1) {1571await new Promise<void>(r => { resolveFirstSend = r; });1572}1573sdkSession.emit('assistant.turn_start', {});1574// On the first (original) request, try to read the file that was1575// attached in the second (steering) request.1576if (thisCallNumber === 1) {1577permissionResult = await sdkSession.emitPermissionRequest({1578kind: 'read', path: attachedFilePath, intention: 'Read file'1579});1580}1581sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1582sdkSession.emit('assistant.turn_end', {});1583};15841585const session = await createSession();1586const stream = new MockChatResponseStream();1587session.attachStream(stream);15881589// Start first request with no attachments1590const firstRequest = session.handleRequest(1591{ id: 'req-1', toolInvocationToken: undefined as never },1592{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None1593);1594await new Promise(r => setTimeout(r, 10));15951596// Send steering request WITH the file attachment1597const steeringAttachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'steering-file.ts' }];1598const steeringRequest = session.handleRequest(1599{ id: 'req-2', toolInvocationToken: undefined as never },1600{ prompt: 'Use that file' }, steeringAttachments as any, undefined, authInfo, CancellationToken.None1601);1602await new Promise(r => setTimeout(r, 10));16031604// Now unblock the first send - it will try to read the steering-attached file1605resolveFirstSend();1606await Promise.all([firstRequest, steeringRequest]);16071608// The file was attached in the steering request, so it should be auto-approved1609expect(permissionResult).toEqual({ kind: 'approve-once' });1610});16111612it('updates the pending prompt to the latest steering message', async () => {1613let resolveFirstSend!: () => void;1614let sendCallCount = 0;1615sdkSession.send = async (options: any) => {1616sendCallCount++;1617sdkSession.lastSendOptions = options;1618if (sendCallCount === 1) {1619await new Promise<void>(r => { resolveFirstSend = r; });1620}1621sdkSession.emit('assistant.turn_start', {});1622sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1623sdkSession.emit('assistant.turn_end', {});1624};16251626const session = await createSession();1627const stream = new MockChatResponseStream();1628session.attachStream(stream);16291630// Start first request1631const firstRequest = session.handleRequest(1632{ id: 'req-1', toolInvocationToken: undefined as never },1633{ prompt: 'Original prompt' }, [], undefined, authInfo, CancellationToken.None1634);1635await new Promise(r => setTimeout(r, 10));1636expect(session.pendingPrompt).toBe('Original prompt');16371638// Steer1639const steeringRequest = session.handleRequest(1640{ id: 'req-2', toolInvocationToken: undefined as never },1641{ prompt: 'New direction' }, [], undefined, authInfo, CancellationToken.None1642);1643await new Promise(r => setTimeout(r, 10));1644expect(session.pendingPrompt).toBe('New direction');16451646resolveFirstSend();1647await Promise.all([firstRequest, steeringRequest]);1648});16491650it('steering request does not change session status to InProgress again', async () => {1651let resolveFirstSend!: () => void;1652let sendCallCount = 0;1653sdkSession.send = async (options: any) => {1654sendCallCount++;1655sdkSession.lastSendOptions = options;1656if (sendCallCount === 1) {1657await new Promise<void>(r => { resolveFirstSend = r; });1658}1659sdkSession.emit('assistant.turn_start', {});1660sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1661sdkSession.emit('assistant.turn_end', {});1662};16631664const session = await createSession();1665const statuses: (ChatSessionStatus | undefined)[] = [];1666disposables.add(session.onDidChangeStatus(s => statuses.push(s)));1667const stream = new MockChatResponseStream();1668session.attachStream(stream);16691670// Start first request1671const firstRequest = session.handleRequest(1672{ id: 'req-1', toolInvocationToken: undefined as never },1673{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None1674);1675await new Promise(r => setTimeout(r, 10));1676// Should have fired InProgress once1677expect(statuses).toEqual([ChatSessionStatus.InProgress]);16781679// Send steering request1680const steeringRequest = session.handleRequest(1681{ id: 'req-2', toolInvocationToken: undefined as never },1682{ prompt: 'Steer' }, [], undefined, authInfo, CancellationToken.None1683);1684await new Promise(r => setTimeout(r, 10));16851686// InProgress should NOT fire again from the steering path1687expect(statuses).toEqual([ChatSessionStatus.InProgress]);16881689resolveFirstSend();1690await Promise.all([firstRequest, steeringRequest]);16911692// Final status should be Completed1693expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Completed]);1694});16951696it('throws on disposed session', async () => {1697const session = await createSession();1698session.dispose();16991700await expect(1701session.handleRequest(1702{ id: 'req-1', toolInvocationToken: undefined as never },1703{ prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None1704)1705).rejects.toThrow('Session disposed');1706});17071708it('updates the toolInvocationToken on each request including steering', async () => {1709let resolveFirstSend!: () => void;1710let sendCallCount = 0;1711sdkSession.send = async (options: any) => {1712sendCallCount++;1713sdkSession.lastSendOptions = options;1714if (sendCallCount === 1) {1715await new Promise<void>(r => { resolveFirstSend = r; });1716}1717sdkSession.emit('assistant.turn_start', {});1718sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1719sdkSession.emit('assistant.turn_end', {});1720};17211722const session = await createSession();1723const stream = new MockChatResponseStream();1724session.attachStream(stream);17251726const token1 = { toString: () => 'token-1' } as unknown as ChatParticipantToolToken;1727const token2 = { toString: () => 'token-2' } as unknown as ChatParticipantToolToken;17281729const firstRequest = session.handleRequest(1730{ id: 'req-1', toolInvocationToken: token1 },1731{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None1732);1733await new Promise(r => setTimeout(r, 10));17341735// Steering replaces the token1736const steeringRequest = session.handleRequest(1737{ id: 'req-2', toolInvocationToken: token2 },1738{ prompt: 'Steer' }, [], undefined, authInfo, CancellationToken.None1739);1740await new Promise(r => setTimeout(r, 10));17411742// Can't directly access private _toolInvocationToken, but we verify1743// indirectly that the session accepted both tokens without error.1744// The key assertion is that handleRequest didn't throw.1745resolveFirstSend();1746await Promise.all([firstRequest, steeringRequest]);1747expect(session.status).toBe(ChatSessionStatus.Completed);1748});17491750it('steering request resolves only after the original request completes', async () => {1751let resolveFirstSend!: () => void;1752let sendCallCount = 0;1753let firstRequestDone = false;1754sdkSession.send = async (options: any) => {1755sendCallCount++;1756sdkSession.lastSendOptions = options;1757if (sendCallCount === 1) {1758await new Promise<void>(r => { resolveFirstSend = r; });1759firstRequestDone = true;1760}1761sdkSession.emit('assistant.turn_start', {});1762sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1763sdkSession.emit('assistant.turn_end', {});1764};17651766const session = await createSession();1767const stream = new MockChatResponseStream();1768session.attachStream(stream);17691770const firstRequest = session.handleRequest(1771{ id: 'req-1', toolInvocationToken: undefined as never },1772{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None1773);1774await new Promise(r => setTimeout(r, 10));17751776let steeringDone = false;1777const steeringRequest = session.handleRequest(1778{ id: 'req-2', toolInvocationToken: undefined as never },1779{ prompt: 'Steer' }, [], undefined, authInfo, CancellationToken.None1780).then(() => { steeringDone = true; });1781await new Promise(r => setTimeout(r, 10));17821783// Steering should not have resolved yet because first request is blocked1784expect(steeringDone).toBe(false);1785expect(firstRequestDone).toBe(false);17861787// Unblock first request1788resolveFirstSend();1789await Promise.all([firstRequest, steeringRequest]);17901791// Both should be done now1792expect(steeringDone).toBe(true);1793expect(firstRequestDone).toBe(true);1794});1795});17961797describe('exit_plan_mode.requested', () => {1798it('does not attach the exit_plan_mode.requested handler when plan exit mode is disabled', async () => {1799await configurationService.setConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled, false);1800sdkSession.send = async (options: any) => {1801sdkSession.lastSendOptions = options;1802expect(sdkSession.onHandlers.get('exit_plan_mode.requested')?.size ?? 0).toBe(0);1803sdkSession.emit('assistant.turn_start', {});1804sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1805sdkSession.emit('assistant.turn_end', {});1806};18071808const session = await createSession();1809const stream = new MockChatResponseStream();1810session.attachStream(stream);18111812await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);1813});18141815function setupSendWithExitPlanMode(data: { summary: string; actions?: string[] }, resultHolder: { value: unknown }) {1816sdkSession.send = async (options: any) => {1817sdkSession.emit('assistant.turn_start', {});1818sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });1819resultHolder.value = await sdkSession.emitExitPlanModeRequest(data);1820sdkSession.emit('assistant.turn_end', {});1821};1822}18231824it('auto-approves with autopilot action when choices include "autopilot"', async () => {1825const result = { value: undefined as unknown };1826setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['autopilot', 'interactive', 'exit_only'] }, result);1827const session = await createSession();1828session.setPermissionLevel('autopilot');1829const stream = new MockChatResponseStream();1830session.attachStream(stream);18311832await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);18331834expect(result.value).toEqual({ approved: true, selectedAction: 'autopilot', autoApproveEdits: true });1835});18361837it('auto-approves with interactive action when choices include "interactive" but not "autopilot"', async () => {1838const result = { value: undefined as unknown };1839setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['interactive', 'exit_only'] }, result);1840const session = await createSession();1841session.setPermissionLevel('autopilot');1842const stream = new MockChatResponseStream();1843session.attachStream(stream);18441845await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);18461847expect(result.value).toEqual({ approved: true, selectedAction: 'interactive' });1848});18491850it('auto-approves with exit_only action when choices include "exit_only" but not "autopilot" or "interactive"', async () => {1851const result = { value: undefined as unknown };1852setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['exit_only'] }, result);1853const session = await createSession();1854session.setPermissionLevel('autopilot');1855const stream = new MockChatResponseStream();1856session.attachStream(stream);18571858await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);18591860expect(result.value).toEqual({ approved: true, selectedAction: 'exit_only' });1861});18621863it('auto-approves with fallback response when no recognized actions are available', async () => {1864const result = { value: undefined as unknown };1865setupSendWithExitPlanMode({ summary: 'Plan ready', actions: [] }, result);1866const session = await createSession();1867session.setPermissionLevel('autopilot');1868const stream = new MockChatResponseStream();1869session.attachStream(stream);18701871await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);18721873expect(result.value).toEqual({ approved: true, autoApproveEdits: true });1874});18751876it('auto-approves with fallback response when actions is undefined', async () => {1877const result = { value: undefined as unknown };1878setupSendWithExitPlanMode({ summary: 'Plan ready' }, result);1879const session = await createSession();1880session.setPermissionLevel('autopilot');1881const stream = new MockChatResponseStream();1882session.attachStream(stream);18831884await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);18851886expect(result.value).toEqual({ approved: true, autoApproveEdits: true });1887});18881889it('denies when no toolInvocationToken is present in non-autopilot mode', async () => {1890const result = { value: undefined as unknown };1891setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['autopilot'] }, result);1892const session = await createSession();1893// No autopilot, no token1894const stream = new MockChatResponseStream();1895session.attachStream(stream);18961897await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);18981899expect(result.value).toEqual({ approved: false });1900});19011902it('approves when user confirms via user question handler in non-autopilot mode', async () => {1903const result = { value: undefined as unknown };1904const summary = 'Here is the plan';1905setupSendWithExitPlanMode({ summary, actions: ['exit_only'] }, result);1906userQuestionAnswer = { selected: ['exit_only'], freeText: null, skipped: false };1907const session = await createSession();1908const stream = new MockChatResponseStream();1909session.attachStream(stream);1910const mockToken = {} as ChatParticipantToolToken;19111912await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);19131914expect(result.value).toEqual({ approved: true, selectedAction: 'exit_only' });1915});19161917it('sets autoApproveEdits when user confirms with autoApprove permission level', async () => {1918const result = { value: undefined as unknown };1919setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);1920userQuestionAnswer = { selected: ['exit_only'], freeText: null, skipped: false };1921const session = await createSession();1922session.setPermissionLevel('autoApprove');1923const stream = new MockChatResponseStream();1924session.attachStream(stream);1925const mockToken = {} as ChatParticipantToolToken;19261927await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);19281929expect(result.value).toEqual({ approved: true, selectedAction: 'exit_only', autoApproveEdits: true });1930});19311932it('does not set autoApproveEdits when user rejects with autoApprove permission level', async () => {1933const result = { value: undefined as unknown };1934setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);1935toolsService.setConfirmationResult('no');1936const session = await createSession();1937session.setPermissionLevel('autoApprove');1938const stream = new MockChatResponseStream();1939session.attachStream(stream);1940const mockToken = {} as ChatParticipantToolToken;19411942await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);19431944expect(result.value).toEqual({ approved: false });1945});19461947it('denies when user rejects via confirmation tool in non-autopilot mode', async () => {1948const result = { value: undefined as unknown };1949setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);1950toolsService.setConfirmationResult('no');1951const session = await createSession();1952const stream = new MockChatResponseStream();1953session.attachStream(stream);1954const mockToken = {} as ChatParticipantToolToken;19551956await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);19571958expect(result.value).toEqual({ approved: false });1959});19601961it('denies when confirmation tool throws in non-autopilot mode', async () => {1962const result = { value: undefined as unknown };1963setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);1964toolsService.invokeTool = vi.fn(async () => { throw new Error('tool error'); });1965const session = await createSession();1966const stream = new MockChatResponseStream();1967session.attachStream(stream);1968const mockToken = {} as ChatParticipantToolToken;19691970await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);19711972expect(result.value).toEqual({ approved: false });1973});1974});19751976describe('usage reporting', () => {1977it('reports usage from assistant.usage event with per-call tokens', async () => {1978sdkSession.send = async (options: any) => {1979sdkSession.emit('user.message', { content: options.prompt });1980sdkSession.emit('assistant.usage', { inputTokens: 200, outputTokens: 80 });1981sdkSession.emit('assistant.turn_end', {});1982};19831984const session = await createSession();1985const stream = new UsageCapturingStream();1986session.attachStream(stream);19871988await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);19891990const usageFromEvent = stream.usages.find(u => u.promptTokens === 200 && u.completionTokens === 80);1991expect(usageFromEvent).toBeDefined();1992});19931994it('reports usage from session.usage_info event immediately', async () => {1995sdkSession.send = async (options: any) => {1996sdkSession.emit('user.message', { content: options.prompt });1997sdkSession.emit('session.usage_info', {1998currentTokens: 500,1999tokenLimit: 8000,2000messagesLength: 5,2001systemTokens: 100,2002conversationTokens: 350,2003toolDefinitionsTokens: 50,2004});2005sdkSession.emit('assistant.turn_end', {});2006};20072008const session = await createSession();2009const stream = new UsageCapturingStream();2010session.attachStream(stream);20112012await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);20132014const usageFromInfo = stream.usages.find(u => u.promptTokens === 500);2015expect(usageFromInfo).toBeDefined();2016expect(usageFromInfo!.completionTokens).toBe(0);2017});20182019it('includes promptTokenDetails breakdown in usage from session.usage_info', async () => {2020sdkSession.send = async (options: any) => {2021sdkSession.emit('user.message', { content: options.prompt });2022sdkSession.emit('session.usage_info', {2023currentTokens: 500,2024tokenLimit: 8000,2025messagesLength: 5,2026systemTokens: 100,2027conversationTokens: 350,2028toolDefinitionsTokens: 50,2029});2030sdkSession.emit('assistant.turn_end', {});2031};20322033const session = await createSession();2034const stream = new UsageCapturingStream();2035session.attachStream(stream);20362037await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);20382039const usageFromInfo = stream.usages.find(u => u.promptTokens === 500);2040expect(usageFromInfo?.promptTokenDetails).toBeDefined();2041expect(usageFromInfo!.promptTokenDetails).toEqual([2042{ category: 'System', label: 'System Instructions', percentageOfPrompt: 20 },2043{ category: 'System', label: 'Tool Definitions', percentageOfPrompt: 10 },2044{ category: 'User Context', label: 'Messages', percentageOfPrompt: 70 },2045]);2046});20472048it('populates promptTokenDetails in assistant.usage event when usage_info was previously received', async () => {2049sdkSession.send = async (options: any) => {2050sdkSession.emit('user.message', { content: options.prompt });2051sdkSession.emit('session.usage_info', {2052currentTokens: 400,2053tokenLimit: 8000,2054messagesLength: 4,2055systemTokens: 80,2056conversationTokens: 280,2057toolDefinitionsTokens: 40,2058});2059sdkSession.emit('assistant.usage', { inputTokens: 400, outputTokens: 60 });2060sdkSession.emit('assistant.turn_end', {});2061};20622063const session = await createSession();2064const stream = new UsageCapturingStream();2065session.attachStream(stream);20662067await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);20682069const assistantUsage = stream.usages.find(u => u.promptTokens === 400 && u.completionTokens === 60);2070expect(assistantUsage).toBeDefined();2071expect(assistantUsage!.promptTokenDetails).toBeDefined();2072expect(assistantUsage!.promptTokenDetails!.length).toBeGreaterThan(0);2073});20742075it('reports final usage from getMetrics() after session completes', async () => {2076sdkSession.usage.getMetrics = async () => ({2077lastCallInputTokens: 350,2078lastCallOutputTokens: 90,2079totalPremiumRequestCost: 0,2080totalUserRequests: 1,2081totalApiDurationMs: 500,2082sessionStartTime: Date.now(),2083codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0 },2084modelMetrics: {},2085currentModel: 'modelA',2086});20872088const session = await createSession();2089const stream = new UsageCapturingStream();2090session.attachStream(stream);20912092await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);20932094const finalUsage = stream.usages.at(-1);2095expect(finalUsage).toBeDefined();2096expect(finalUsage!.completionTokens).toBe(90);2097});20982099it('uses currentTokens from session.usage_info as promptTokens in final usage report (non-zero after compaction)', async () => {2100sdkSession.send = async (options: any) => {2101sdkSession.emit('user.message', { content: options.prompt });2102// Simulate post-compaction: usage_info fires with reduced token count, no assistant.usage follows2103sdkSession.emit('session.usage_info', {2104currentTokens: 120,2105tokenLimit: 8000,2106messagesLength: 2,2107systemTokens: 80,2108conversationTokens: 40,2109toolDefinitionsTokens: 0,2110});2111sdkSession.emit('assistant.turn_end', {});2112};2113sdkSession.usage.getMetrics = async () => ({2114lastCallInputTokens: 0, // stale / no new call made2115lastCallOutputTokens: 0,2116totalPremiumRequestCost: 0,2117totalUserRequests: 1,2118totalApiDurationMs: 0,2119sessionStartTime: Date.now(),2120codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0 },2121modelMetrics: {},2122currentModel: 'modelA',2123});21242125const session = await createSession();2126const stream = new UsageCapturingStream();2127session.attachStream(stream);21282129await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);21302131// Final usage should use currentTokens (120) not the stale lastCallInputTokens (0)2132const finalUsage = stream.usages.at(-1);2133expect(finalUsage!.promptTokens).toBe(120);2134});2135});2136});213721382139