Skip to content
Merged
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
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4449,6 +4449,15 @@
"experimental"
]
},
"github.copilot.chat.cli.planMode.enabled": {
"type": "boolean",
"default": false,
"markdownDescription": "%github.copilot.config.cli.planMode.enabled%",
"tags": [
"advanced",
"experimental"
]
},
"github.copilot.chat.cli.mcp.enabled": {
"type": "boolean",
"default": false,
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@
"github.copilot.config.claudeAgent.enabled": "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic's Claude Agent SDK directly in the editor. Uses your existing Copilot subscription.",
"github.copilot.config.claudeAgent.allowDangerouslySkipPermissions": "Allow bypass permissions mode. Recommended only for sandboxes with no internet access.",
"github.copilot.config.cli.customAgents.enabled": "Enable Custom Agents for Background Agents.",
"github.copilot.config.cli.planMode.enabled": "Enable Plan Mode for Background Agents.",
"github.copilot.config.cli.mcp.enabled": "Enable Model Context Protocol (MCP) server for Background Agents.",
"github.copilot.config.cli.branchSupport.enabled": "Enable branch support for Background Agents.",
"github.copilot.config.cli.isolationOption.enabled": "Enable the isolation mode option for Background Agents. When enabled, users can choose between Worktree and Workspace modes.",
Expand Down
9 changes: 7 additions & 2 deletions src/extension/agents/copilotcli/node/copilotcliSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact'] as c
* Either a free-form prompt **or** a known command.
*/
export type CopilotCLISessionInput =
| { readonly prompt: string }
| { readonly prompt: string; plan?: boolean }
| { readonly command: CopilotCLICommand };

type PermissionHandler = (
Expand Down Expand Up @@ -404,13 +404,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
})));

this._logRequest(prompt, modelId || '', attachments, logStartTime);

if (!token.isCancellationRequested) {
if ('command' in input) {
switch (input.command) {
case 'compact': {
this._stream?.progress(l10n.t('Compacting conversation...'));
await this._sdkSession.initializeAndValidateTools();
this._sdkSession.currentMode = 'interactive';
const result = await this._sdkSession.compactHistory();
if (result.success) {
this._stream?.markdown(l10n.t('Compacted conversation.'));
Expand All @@ -421,6 +421,11 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}
}
} else {
if (input.plan) {
this._sdkSession.currentMode = 'plan';
} else {
this._sdkSession.currentMode = 'interactive';
}
await this._sdkSession.send({ prompt: input.prompt, attachments, abortController });
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ export const ICopilotCLISessionService = createServiceIdentifier<ICopilotCLISess

const SESSION_SHUTDOWN_TIMEOUT_MS = 300 * 1000;

type TelemetryService = ConstructorParameters<typeof internal.LocalSessionManager>[0]['telemetryService'];

export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService {
declare _serviceBrand: undefined;

Expand Down Expand Up @@ -98,10 +96,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
super();
this.monitorSessionFiles();
this._sessionManager = new Lazy<Promise<internal.LocalSessionManager>>(async () => {
const { internal } = await this.copilotCLISDK.getPackage();
try {
const telemetryService = new internal.NoopTelemetryService() as unknown as TelemetryService;
return new internal.LocalSessionManager({ telemetryService, flushDebounceMs: undefined, settings: undefined, version: undefined });
const { internal } = await this.copilotCLISDK.getPackage();
return new internal.LocalSessionManager({ telemetryService: new internal.NoopTelemetryService(), flushDebounceMs: undefined, settings: undefined, version: undefined });
}
catch (error) {
this.logService.error(`Failed to initialize Copilot CLI Session Manager: ${error}`);
Expand Down
7 changes: 7 additions & 0 deletions src/extension/chatSessions/vscode-node/chatSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IEnvService, INativeEnvService } from '../../../platform/env/common/envService';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { IGitService } from '../../../platform/git/common/gitService';
Expand Down Expand Up @@ -43,6 +44,7 @@ import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderS
import { ChatSessionWorktreeService } from './chatSessionWorktreeServiceImpl';
import { ClaudeChatSessionContentProvider, ClaudeSessionUri } from './claudeChatSessionContentProvider';
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessionsContribution';
import { PlanAgentProvider } from './copilotCLIPlanAgentProvider';
import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from './folderRepositoryManagerImpl';
Expand Down Expand Up @@ -153,11 +155,16 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService));
const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService));
const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels));
const configurationService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IConfigurationService));

this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)));
this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib));

copilotModels.registerLanguageModelChatProvider(vscode.lm);
if (configurationService.getConfig(ConfigKey.Advanced.CLIPlanModeEnabled)) {
const planProvider = this._register(copilotcliAgentInstaService.createInstance(PlanAgentProvider));
this._register(vscode.chat.registerCustomAgentProvider(planProvider));
}

const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler());
this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { isUntitledSessionId } from '../common/utils';
import { convertReferenceToVariable } from './copilotCLIPromptReferences';
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
import { isCopilotCLIPlanAgent } from './copilotCLIPlanAgentProvider';

const AGENTS_OPTION_ID = 'agent';
const REPOSITORY_OPTION_ID = 'repository';
Expand Down Expand Up @@ -999,8 +1000,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
await this.commitWorktreeChangesIfNeeded(session.object, token);
} else {
// Construct the full prompt with references to be sent to CLI.
const plan = request.modeInstructions2 ? isCopilotCLIPlanAgent(request.modeInstructions2) : false;
const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.options.isolationEnabled, session.object.options.workingDirectory, token);
await session.object.handleRequest(request, { prompt }, attachments, modelId, authInfo, token);
await session.object.handleRequest(request, { prompt, plan }, attachments, modelId, authInfo, token);
await this.commitWorktreeChangesIfNeeded(session.object, token);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Uri, type CancellationToken, type ChatCustomAgentProvider, type ChatRequestModeInstructions, type ChatResource } from 'vscode';
import { AGENT_FILE_EXTENSION } from '../../../platform/customInstructions/common/promptTypes';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { ILogService } from '../../../platform/log/common/logService';
import { Emitter } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';

const planPrompt = `Plan model is configured and defined within Github Copilot CLI`;

export function isCopilotCLIPlanAgent(mode: ChatRequestModeInstructions) {
return mode.name.toLowerCase() === 'plan' && mode.content.trim().includes(planPrompt.trim());
Comment thread
DonJayamanne marked this conversation as resolved.
}

export class PlanAgentProvider extends Disposable implements ChatCustomAgentProvider {
Comment thread
DonJayamanne marked this conversation as resolved.
private static readonly CACHE_DIR = 'github.copilotcli';
private static readonly AGENT_FILENAME = `Plan${AGENT_FILE_EXTENSION}`;

private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event;

constructor(
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@ILogService private readonly logService: ILogService,
) {
super();
}

async provideCustomAgents(
_context: unknown,
_token: CancellationToken
): Promise<ChatResource[]> {
// Generate .agent.md content
const content = `---
name: Plan
description: Github Copilot CLI Plan agent
Comment thread
DonJayamanne marked this conversation as resolved.
target: github-copilot
Comment thread
DonJayamanne marked this conversation as resolved.
---
${planPrompt}
For more details on Plan mode, see https://github.blog/changelog/2026-01-21-github-copilot-cli-plan-before-you-build-steer-as-you-go/#plan-mode`;

// Write to cache file and return URI
const fileUri = await this.writeCacheFile(content);
return [{ uri: fileUri }];
}

private async writeCacheFile(content: string): Promise<Uri> {
const cacheDir = Uri.joinPath(
this.extensionContext.globalStorageUri,
PlanAgentProvider.CACHE_DIR
);

// Ensure cache directory exists
try {
await this.fileSystemService.stat(cacheDir);
} catch {
await this.fileSystemService.createDirectory(cacheDir);
}

const fileUri = Uri.joinPath(cacheDir, PlanAgentProvider.AGENT_FILENAME);
await this.fileSystemService.writeFile(fileUri, new TextEncoder().encode(content));
this.logService.trace(`[PlanAgentProvider] Wrote agent file: ${fileUri.toString()}`);
return fileUri;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import { NullTelemetryService } from '../../../../platform/telemetry/common/null
import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
import { IWorkspaceService, NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';
import { mock } from '../../../../util/common/test/simpleMock';
import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
import { sep } from '../../../../util/vs/base/common/path';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';
import { IChatDelegationSummaryService } from '../../../agents/copilotcli/common/delegationSummaryService';
import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../../agents/copilotcli/node/copilotCli';
import { CopilotCLIPromptResolver } from '../../../agents/copilotcli/node/copilotcliPromptResolver';
Expand Down Expand Up @@ -362,7 +362,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {

expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], modelId: 'base', authInfo, token });
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi', plan: false }, attachments: [], modelId: 'base', authInfo, token });
});

it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => {
Expand Down Expand Up @@ -434,7 +434,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].sessionId).toBe(sessionId);
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Continue' }, attachments: [], modelId: 'base', authInfo, token });
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Continue', plan: false }, attachments: [], modelId: 'base', authInfo, token });

expect(itemProvider.swap).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -559,7 +559,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
// Should call session.handleRequest normally
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'my prompt' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'my prompt', plan: false });
});

it('handles existing session with rejectedConfirmationData (proceeds normally)', async () => {
Expand All @@ -577,7 +577,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
// Should proceed normally (no cloud delegation)
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Apply' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Apply', plan: false });
});

it('handles existing session with unknown step acceptedConfirmationData (proceeds normally)', async () => {
Expand Down Expand Up @@ -613,7 +613,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
// Session should be created in one request (no separate confirmation round-trip)
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug', plan: false });
// Verify confirmation tool was invoked with the right title
expect(tools.invokeTool).toHaveBeenCalledWith(
'vscode_get_confirmation_with_options',
Expand All @@ -638,7 +638,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
// Should create session and use request.prompt directly
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug', plan: false });
// Verify promptResolver was called without override prompt
expect(promptResolver.resolvePrompt).toHaveBeenCalled();
expect((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1]).toBeUndefined();
Expand Down Expand Up @@ -714,7 +714,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
// Should create session directly without confirmation
expect(tools.invokeTool).not.toHaveBeenCalled();
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug', plan: false });
});

it('does not prompt for confirmation for existing (non-untitled) session with uncommitted changes', async () => {
Expand All @@ -733,7 +733,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
// Should not prompt for confirmation for existing sessions
expect(tools.invokeTool).not.toHaveBeenCalled();
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Continue work' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Continue work', plan: false });
});

it('reuses untitled session without uncommitted changes instead of creating new session', async () => {
Expand Down Expand Up @@ -761,8 +761,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].sessionId).toBe(firstSessionId);
expect(cliSessions[0].requests.length).toBe(2);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request' });
expect(cliSessions[0].requests[1].input).toEqual({ prompt: 'Second request' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request', plan: false });
expect(cliSessions[0].requests[1].input).toEqual({ prompt: 'Second request', plan: false });
});

it('reuses untitled session after confirmation without creating new session', async () => {
Expand All @@ -785,7 +785,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
expect(cliSessions.length).toBe(1);
const firstSessionId = cliSessions[0].sessionId;
expect(cliSessions[0].requests.length).toBe(1);
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request' });
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request', plan: false });

// Second request should reuse the same session
const request2 = new TestChatRequest('Second request');
Expand All @@ -799,7 +799,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
expect(cliSessions.length).toBe(1);
expect(cliSessions[0].sessionId).toBe(firstSessionId);
expect(cliSessions[0].requests.length).toBe(2);
expect(cliSessions[0].requests[1].input).toEqual({ prompt: 'Second request' });
expect(cliSessions[0].requests[1].input).toEqual({ prompt: 'Second request', plan: false });
});

describe('Authorization check', () => {
Expand Down
1 change: 1 addition & 0 deletions src/platform/configuration/common/configurationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ export namespace ConfigKey {
export const UseResponsesApiTruncation = defineAndMigrateSetting<boolean | undefined>('chat.advanced.useResponsesApiTruncation', 'chat.useResponsesApiTruncation', false);
export const OmitBaseAgentInstructions = defineAndMigrateSetting<boolean>('chat.advanced.omitBaseAgentInstructions', 'chat.omitBaseAgentInstructions', false);
export const CLICustomAgentsEnabled = defineAndMigrateSetting<boolean | undefined>('chat.advanced.cli.customAgents.enabled', 'chat.cli.customAgents.enabled', true);
export const CLIPlanModeEnabled = defineAndMigrateSetting<boolean | undefined>('chat.advanced.cli.planMode.enabled', 'chat.cli.planMode.enabled', false);
export const CLIMCPServerEnabled = defineAndMigrateSetting<boolean | undefined>('chat.advanced.cli.mcp.enabled', 'chat.cli.mcp.enabled', false);
export const CLIBranchSupport = defineSetting<boolean>('chat.cli.branchSupport.enabled', ConfigType.Simple, false);
export const CLIIsolationOption = defineSetting<boolean>('chat.cli.isolationOption.enabled', ConfigType.Simple, false);
Expand Down