diff --git a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 74b7e4f4bb..67214b4af5 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -514,12 +514,6 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes }))); } disposables.add(toDisposable(this._sdkSession.on('user_input.requested', async (event) => { - // auto approve user input - if (this._permissionLevel === 'autopilot') { - this.logService.trace('[CopilotCLISession] Auto-responding to user input in autopilot'); - this._sdkSession.respondToUserInput(event.data.requestId, { answer: 'The user is not available to respond and will review your work later. Work autonomously and make good decisions.', wasFreeform: true }); - return; - } if (!(this._toolInvocationToken as unknown)) { this.logService.warn('[AskQuestionsTool] No stream available, cannot show question carousel'); this._sdkSession.respondToUserInput(event.data.requestId, { answer: '', wasFreeform: false }); diff --git a/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 88d04894c2..80a8123d48 100644 --- a/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -48,6 +48,8 @@ class MockSdkSession { private _permissionCounter = 0; private _pendingExitPlanMode = new Map void }>(); private _exitPlanModeCounter = 0; + private _pendingUserInput = new Map void }>(); + private _userInputCounter = 0; on(event: string, handler: MockSdkEventHandler) { if (!this.onHandlers.has(event)) { @@ -101,8 +103,20 @@ class MockSdkSession { } } - respondToUserInput(_requestId: string, _response: unknown) { - // placeholder for user input responses + async emitUserInputRequest(data: { question: string; choices?: string[]; allowFreeform: boolean }): Promise { + const requestId = `user-input-${++this._userInputCounter}`; + return new Promise(resolve => { + this._pendingUserInput.set(requestId, { resolve }); + this.emit('user_input.requested', { requestId, ...data }); + }); + } + + respondToUserInput(requestId: string, response: unknown) { + const pending = this._pendingUserInput.get(requestId); + if (pending) { + pending.resolve(response); + this._pendingUserInput.delete(requestId); + } } public lastSendOptions: { prompt: string; mode?: string } | undefined; @@ -172,6 +186,7 @@ describe('CopilotCLISession', () => { let chatSessionMetadataStore: MockChatSessionMetadataStore; let authInfo: NonNullable; let userQuestionAnswer: IQuestionAnswer | undefined; + let userQuestionRequests: IQuestion[]; beforeEach(async () => { const services = disposables.add(createExtensionUnitTestingServices()); const accessor = services.createTestingAccessor(); @@ -192,6 +207,7 @@ describe('CopilotCLISession', () => { instaService = services.seal(); toolsService = new FakeToolsService(); userQuestionAnswer = undefined; + userQuestionRequests = []; }); afterEach(() => { @@ -204,6 +220,7 @@ describe('CopilotCLISession', () => { class FakeUserQuestionHandler implements IUserQuestionHandler { _serviceBrand: undefined; async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise { + userQuestionRequests.push(question); return userQuestionAnswer; } } @@ -1048,6 +1065,64 @@ describe('CopilotCLISession', () => { }); }); + describe('user_input.requested', () => { + it('routes autopilot requests through askUserQuestion and returns selected choice', async () => { + const result = { value: undefined as unknown }; + userQuestionAnswer = { selected: ['Option B'], freeText: null, skipped: false }; + sdkSession.send = async (options: any) => { + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` }); + result.value = await sdkSession.emitUserInputRequest({ + question: 'Which approach do you want?', + choices: ['Option A', 'Option B'], + allowFreeform: false, + }); + sdkSession.emit('assistant.turn_end', {}); + }; + + const session = await createSession(); + session.setPermissionLevel('autopilot'); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + const mockToken = {} as ChatParticipantToolToken; + + await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None); + + expect(userQuestionRequests).toEqual([{ + header: 'Which approach do you want?', + question: 'Which approach do you want?', + options: [{ label: 'Option A' }, { label: 'Option B' }], + allowFreeformInput: false, + }]); + expect(result.value).toEqual({ answer: 'Option B', wasFreeform: false }); + }); + + it('returns freeform answer for autopilot user input requests', async () => { + const result = { value: undefined as unknown }; + userQuestionAnswer = { selected: [], freeText: 'Use inline editing in each card.', skipped: false }; + sdkSession.send = async (options: any) => { + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` }); + result.value = await sdkSession.emitUserInputRequest({ + question: 'How should groups be edited?', + choices: ['Dialog', 'Inline'], + allowFreeform: true, + }); + sdkSession.emit('assistant.turn_end', {}); + }; + + const session = await createSession(); + session.setPermissionLevel('autopilot'); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + const mockToken = {} as ChatParticipantToolToken; + + await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None); + + expect(result.value).toEqual({ answer: 'Use inline editing in each card.', wasFreeform: true }); + }); + }); + describe('exit_plan_mode.requested', () => { it('does not attach the exit_plan_mode.requested handler when plan exit mode is disabled', async () => { await configurationService.setConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled, false);