Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class MockSdkSession {
private _permissionCounter = 0;
private _pendingExitPlanMode = new Map<string, { resolve: (result: unknown) => void }>();
private _exitPlanModeCounter = 0;
private _pendingUserInput = new Map<string, { resolve: (result: unknown) => void }>();
private _userInputCounter = 0;

on(event: string, handler: MockSdkEventHandler) {
if (!this.onHandlers.has(event)) {
Expand Down Expand Up @@ -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<unknown> {
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;
Expand Down Expand Up @@ -172,6 +186,7 @@ describe('CopilotCLISession', () => {
let chatSessionMetadataStore: MockChatSessionMetadataStore;
let authInfo: NonNullable<SessionOptions['authInfo']>;
let userQuestionAnswer: IQuestionAnswer | undefined;
let userQuestionRequests: IQuestion[];
beforeEach(async () => {
const services = disposables.add(createExtensionUnitTestingServices());
const accessor = services.createTestingAccessor();
Expand All @@ -192,6 +207,7 @@ describe('CopilotCLISession', () => {
instaService = services.seal();
toolsService = new FakeToolsService();
userQuestionAnswer = undefined;
userQuestionRequests = [];
});

afterEach(() => {
Expand All @@ -204,6 +220,7 @@ describe('CopilotCLISession', () => {
class FakeUserQuestionHandler implements IUserQuestionHandler {
_serviceBrand: undefined;
async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise<IQuestionAnswer | undefined> {
userQuestionRequests.push(question);
return userQuestionAnswer;
}
}
Expand Down Expand Up @@ -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);
Expand Down
Loading