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
4 changes: 3 additions & 1 deletion src/extension/agents/copilotcli/node/copilotCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const DEFAULT_CLI_MODEL = 'claude-sonnet-4';
export interface CopilotCLISessionOptions {
addPermissionHandler(handler: SessionOptions['requestPermission']): IDisposable;
toSessionOptions(): SessionOptions;
isolationEnabled: boolean;
}

export interface ICopilotCLIModels {
Expand Down Expand Up @@ -171,7 +172,8 @@ export class CopilotCLISessionOptionsService implements ICopilotCLISessionOption
}
});
},
toSessionOptions: () => allOptions
toSessionOptions: () => allOptions,
isolationEnabled: false
} satisfies CopilotCLISessionOptions;
}
private async getWorkspaceFolderPath() {
Expand Down
12 changes: 12 additions & 0 deletions src/extension/agents/copilotcli/node/copilotcliSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { Attachment, Session } from '@github/copilot/sdk';
import type * as vscode from 'vscode';
import { IGitService } from '../../../../platform/git/common/gitService';
import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
Expand Down Expand Up @@ -67,6 +68,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
constructor(
private readonly _options: CopilotCLISessionOptions,
private readonly _sdkSession: Session,
@IGitService private readonly gitService: IGitService,
@ILogService private readonly logService: ILogService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@ICopilotCLISessionOptionsService private readonly cliSessionOptions: ICopilotCLISessionOptionsService,
Expand Down Expand Up @@ -199,6 +201,16 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
await this._sdkSession.send({ prompt, attachments, abortController });
this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`);

if (this._options.isolationEnabled) {
// When isolation is enabled and we are using a git workspace, stage
// all changes in the working directory when the session is completed
const workingDirectory = this._options.toSessionOptions().workingDirectory;
if (workingDirectory) {
await this.gitService.add(Uri.file(workingDirectory), []);
this.logService.trace(`[CopilotCLISession] Staged all changes in working directory ${workingDirectory}`);
}
}

this._status = ChatSessionStatus.Completed;
this._statusChange.fire(this._status);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface ICopilotCLISessionService {

// Session wrapper tracking
getSession(sessionId: string, model: string | undefined, workingDirectory: string | undefined, readonly: boolean, token: CancellationToken): Promise<ICopilotCLISession | undefined>;
createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, token: CancellationToken): Promise<ICopilotCLISession>;
createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, isolationEnabled: boolean | undefined, token: CancellationToken): Promise<ICopilotCLISession>;
}

export const ICopilotCLISessionService = createServiceIdentifier<ICopilotCLISessionService>('ICopilotCLISessionService');
Expand Down Expand Up @@ -183,13 +183,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return { session, dispose: () => Promise.resolve() };
}

public async createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, token: CancellationToken): Promise<CopilotCLISession> {
public async createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, isolationEnabled: boolean | undefined, token: CancellationToken): Promise<CopilotCLISession> {
const sessionDisposables = this._register(new DisposableStore());
try {
const options = await raceCancellationError(this.optionsService.createOptions({
model: model as unknown as ModelMetadata['model'],
workingDirectory
}), token);
options.isolationEnabled = isolationEnabled === true;
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
const sdkSession = await sessionManager.createSession(options.toSessionOptions());
const chatMessages = await sdkSession.getChatContextMessages();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { DisposableStore, IDisposable } from '../../../../../util/vs/base/common
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
import { CancellationTokenSource, ChatSessionStatus, EventEmitter } from '../../../../../vscodeTypes';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { ICopilotCLISDK, ICopilotCLISessionOptionsService } from '../copilotCli';
import { CopilotCLISessionOptions, ICopilotCLISDK, ICopilotCLISessionOptionsService } from '../copilotCli';
import { ICopilotCLISession } from '../copilotcliSession';
import { CopilotCLISessionService } from '../copilotcliSessionService';

Expand Down Expand Up @@ -84,8 +84,9 @@ describe('CopilotCLISessionService', () => {
createOptions: vi.fn(async (opts: any) => {
return {
addPermissionHandler: () => ({ dispose() { /* noop */ } }),
toSessionOptions: () => ({ ...opts, requestPermission: async () => ({ kind: 'denied-interactively-by-user' }) })
};
toSessionOptions: () => ({ ...opts, requestPermission: async () => ({ kind: 'denied-interactively-by-user' }) }),
isolationEnabled: false
} satisfies CopilotCLISessionOptions;
}),
};
const sdk = {
Expand Down Expand Up @@ -137,15 +138,15 @@ describe('CopilotCLISessionService', () => {

describe('CopilotCLISessionService.createSession', () => {
it('get session will return the same session created using createSession', async () => {
const session = await service.createSession(' ', 'gpt-test', '/tmp', createToken().token);
const session = await service.createSession(' ', 'gpt-test', '/tmp', false, createToken().token);
expect(optionsService.createOptions).toHaveBeenCalledWith({ model: 'gpt-test', workingDirectory: '/tmp' });

const existingSession = await service.getSession(session.sessionId, undefined, undefined, false, createToken().token);

expect(existingSession).toBe(session);
});
it('get session will return new once previous session is disposed', async () => {
const session = await service.createSession(' ', 'gpt-test', '/tmp', createToken().token);
const session = await service.createSession(' ', 'gpt-test', '/tmp', false, createToken().token);
expect(optionsService.createOptions).toHaveBeenCalledWith({ model: 'gpt-test', workingDirectory: '/tmp' });

session.dispose();
Expand All @@ -166,7 +167,7 @@ describe('CopilotCLISessionService', () => {

describe('CopilotCLISessionService.getAllSessions', () => {
it('will not list created sessions', async () => {
await service.createSession(' ', 'gpt-test', '/tmp', createToken().token);
await service.createSession(' ', 'gpt-test', '/tmp', false, createToken().token);

const s1 = new FakeSdkSession('s1', new Date(0));
s1.messages.push({ role: 'user', content: 'a'.repeat(100) });
Expand All @@ -186,7 +187,7 @@ describe('CopilotCLISessionService', () => {

describe('CopilotCLISessionService.deleteSession', () => {
it('disposes active wrapper, removes from manager and fires change event', async () => {
const session = await service.createSession('to delete', undefined, undefined, createToken().token);
const session = await service.createSession('to delete', undefined, undefined, undefined, createToken().token);
const id = session!.sessionId;
let fired = false;
disposables.add(service.onDidChangeSessions(() => { fired = true; }));
Expand Down Expand Up @@ -214,7 +215,7 @@ describe('CopilotCLISessionService', () => {
describe('CopilotCLISessionService.auto disposal timeout', () => {
it.skip('disposes session after completion timeout and aborts underlying sdk session', async () => {
vi.useFakeTimers();
const session = await service.createSession('will timeout', undefined, undefined, createToken().token);
const session = await service.createSession('will timeout', undefined, undefined, undefined, createToken().token);

vi.advanceTimersByTime(31000);
await Promise.resolve(); // allow any pending promises to run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { CopilotCLISessionOptions, ICopilotCLISessionOptionsService } from '../c
import { CopilotCLISession } from '../copilotcliSession';
import { CopilotCLIToolNames } from '../copilotcliToolInvocationFormatter';
import { PermissionRequest } from '../permissionHelpers';
import { IGitService } from '../../../../../platform/git/common/gitService';

// Minimal shapes for types coming from the Copilot SDK we interact with
interface MockSdkEventHandler { (payload: unknown): void }
Expand Down Expand Up @@ -77,7 +78,8 @@ function createSessionOptionsService() {
permissionHandler = h;
return { dispose: () => { permissionHandler = undefined; } };
},
toSessionOptions: () => allOptions
toSessionOptions: () => allOptions,
isolationEnabled: false
} satisfies CopilotCLISessionOptions;
}
};
Expand All @@ -104,13 +106,15 @@ describe('CopilotCLISession', () => {
let sdkSession: MockSdkSession;
let workspaceService: IWorkspaceService;
let logger: ILogService;
let gitService: IGitService;
let sessionOptionsService: ICopilotCLISessionOptionsService;
let sessionOptions: CopilotCLISessionOptions;

beforeEach(async () => {
const services = disposables.add(createExtensionUnitTestingServices());
const accessor = services.createTestingAccessor();
logger = accessor.get(ILogService);
gitService = accessor.get(IGitService);

sdkSession = new MockSdkSession();
sessionOptionsService = createSessionOptionsService();
Expand All @@ -128,6 +132,7 @@ describe('CopilotCLISession', () => {
return disposables.add(new CopilotCLISession(
sessionOptions,
sdkSession as unknown as Session,
gitService,
logger,
workspaceService,
sessionOptionsService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
private readonly worktreeManager: CopilotCLIWorktreeManager,
@ICopilotCLISessionService private readonly copilotcliSessionService: ICopilotCLISessionService,
@ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration,
@IGitService private readonly gitService: IGitService,
@IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService
) {
super();
Expand All @@ -154,36 +155,47 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc

public async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {
const sessions = await this.copilotcliSessionService.getAllSessions(token);
const diskSessions = sessions.map(session => this._toChatSessionItem(session));
const diskSessions = await Promise.all(sessions.map(session => this._toChatSessionItem(session)));

const count = diskSessions.length;
this.commandExecutionService.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0);

return diskSessions;
}

private _toChatSessionItem(session: { id: string; label: string; timestamp: Date; status?: vscode.ChatSessionStatus }): vscode.ChatSessionItem {
private async _toChatSessionItem(session: { id: string; label: string; timestamp: Date; status?: vscode.ChatSessionStatus }): Promise<vscode.ChatSessionItem> {
const resource = SessionIdForCLI.getResource(session.id);
const label = session.label || 'Copilot CLI';
const worktreePath = this.worktreeManager.getWorktreeRelativePath(session.id);
const worktreePath = this.worktreeManager.getWorktreePath(session.id);
const worktreeRelativePath = this.worktreeManager.getWorktreeRelativePath(session.id);

const label = session.label ?? 'Copilot CLI';
const tooltipLines = [`Copilot CLI session: ${label}`];
let description: vscode.MarkdownString | undefined;
if (worktreePath) {
description = new vscode.MarkdownString(`$(git-merge) ${worktreePath}`);
let statistics: { files: number; insertions: number; deletions: number } | undefined;

if (worktreePath && worktreeRelativePath) {
// Description
description = new vscode.MarkdownString(`$(list-tree) ${worktreeRelativePath}`);
description.supportThemeIcons = true;

// Tooltip
tooltipLines.push(`Worktree: ${worktreeRelativePath}`);

// Statistics
statistics = await this.gitService.diffIndexWithHEADShortStats(Uri.file(worktreePath));
}
const tooltipLines = [`Copilot CLI session: ${label}`];
if (worktreePath) {
tooltipLines.push(`Worktree: ${worktreePath}`);
}

const status = session.status ?? vscode.ChatSessionStatus.Completed;

return {
resource,
label,
description,
tooltip: tooltipLines.join('\n'),
timing: { startTime: session.timestamp.getTime() },
statistics,
status
};
} satisfies vscode.ChatSessionItem;
}

public async createCopilotCLITerminal(): Promise<void> {
Expand Down Expand Up @@ -380,8 +392,10 @@ export class CopilotCLIChatSessionParticipant {
(this.worktreeManager.getIsolationPreference(id) ? await this.worktreeManager.createWorktree(stream) : undefined) :
this.worktreeManager.getWorktreePath(id);

const isolationEnabled = this.worktreeManager.getIsolationPreference(id);

const session = chatSessionContext.isUntitled ?
await this.sessionService.createSession(prompt, modelId, workingDirectory, token) :
await this.sessionService.createSession(prompt, modelId, workingDirectory, isolationEnabled, token) :
await this.sessionService.getSession(id, undefined, workingDirectory, false, token);

if (!session) {
Expand Down Expand Up @@ -478,7 +492,7 @@ export class CopilotCLIChatSessionParticipant {
const history = context.chatSummary?.history ?? await this.summarizer.provideChatSummary(context, token);

const requestPrompt = history ? `${prompt}\n**Summary**\n${history}` : prompt;
const session = await this.sessionService.createSession(requestPrompt, undefined, undefined, token);
const session = await this.sessionService.createSession(requestPrompt, undefined, undefined, undefined, token);

await this.commandExecutionService.executeCommand('vscode.open', SessionIdForCLI.getResource(session.sessionId));
await this.commandExecutionService.executeCommand('workbench.action.chat.submit', { inputValue: requestPrompt });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ class FakeWorktreeManager extends mock<CopilotCLIWorktreeManager>() {
override createWorktree = vi.fn(async () => undefined);
override storeWorktreePath = vi.fn(async () => { });
override getWorktreePath = vi.fn((_id: string) => undefined);
override getIsolationPreference = vi.fn(() => true);
override getIsolationPreference = vi.fn(() => false);
}

interface CreateSessionArgs { prompt: string | undefined; modelId: string | undefined; workingDirectory: string | undefined }
interface CreateSessionArgs { prompt: string | undefined; modelId: string | undefined; workingDirectory: string | undefined; isolationEnabled: boolean | undefined }

class FakeCopilotCLISession implements ICopilotCLISession {
public sessionId: string;
Expand All @@ -88,8 +88,8 @@ class FakeCopilotCLISession implements ICopilotCLISession {
class FakeSessionService extends DisposableStore implements ICopilotCLISessionService {
_serviceBrand: undefined;
public createdArgs: CreateSessionArgs | undefined;
public createSession = vi.fn(async (prompt: string | undefined, modelId: string | undefined, workingDirectory: string | undefined) => {
this.createdArgs = { prompt, modelId, workingDirectory };
public createSession = vi.fn(async (prompt: string | undefined, modelId: string | undefined, workingDirectory: string | undefined, isolationEnabled: boolean | undefined) => {
this.createdArgs = { prompt, modelId, workingDirectory, isolationEnabled };
const s = new FakeCopilotCLISession('new-session-id');
return s as unknown as ICopilotCLISession;
});
Expand Down Expand Up @@ -285,7 +285,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
await participant.createHandler()(request, context, stream, token);

const expectedPrompt = 'Push this\n**Summary**\nsummary text';
expect(sessionService.createSession).toHaveBeenCalledWith(expectedPrompt, undefined, undefined, token);
expect(sessionService.createSession).toHaveBeenCalledWith(expectedPrompt, undefined, undefined, undefined, token);
expect(summarySpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledTimes(2);
expect(execSpy.mock.calls[0]).toEqual(['vscode.open', expect.any(Object)]);
Expand All @@ -304,7 +304,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
await participant.createHandler()(request, context, stream, token);

const expectedPrompt = 'Push that\n**Summary**\nprecomputed history';
expect(sessionService.createSession).toHaveBeenCalledWith(expectedPrompt, undefined, undefined, token);
expect(sessionService.createSession).toHaveBeenCalledWith(expectedPrompt, undefined, undefined, undefined, token);
expect(summarySpy).not.toHaveBeenCalled();
expect(execSpy).toHaveBeenCalledTimes(2);
expect(execSpy.mock.calls[0].at(0)).toBe('vscode.open');
Expand Down
2 changes: 2 additions & 0 deletions src/extension/prompt/node/test/repoInfoTelemetry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ suite('RepoInfoTelemetry', () => {
log: vi.fn(),
diffBetween: vi.fn(),
diffWith: vi.fn(),
diffIndexWithHEADShortStats: vi.fn(),
fetch: vi.fn(),
getMergeBase: vi.fn(),
add: vi.fn(),
dispose: vi.fn()
};
services.define(IGitService, mockGitService);
Expand Down
4 changes: 3 additions & 1 deletion src/platform/git/common/gitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Event } from '../../../util/vs/base/common/event';
import { IObservable } from '../../../util/vs/base/common/observableInternal';
import { equalsIgnoreCase } from '../../../util/vs/base/common/strings';
import { URI } from '../../../util/vs/base/common/uri';
import { Change, Commit, LogOptions } from '../vscode/git';
import { Change, Commit, CommitShortStat, LogOptions } from '../vscode/git';

export interface RepoContext {
readonly rootUri: URI;
Expand Down Expand Up @@ -52,9 +52,11 @@ export interface IGitService extends IDisposable {
getRepository(uri: URI): Promise<RepoContext | undefined>;
getRepositoryFetchUrls(uri: URI): Promise<Pick<RepoContext, 'rootUri' | 'remoteFetchUrls'> | undefined>;
initialize(): Promise<void>;
add(uri: URI, paths: string[]): Promise<void>;
log(uri: URI, options?: LogOptions): Promise<Commit[] | undefined>;
diffBetween(uri: URI, ref1: string, ref2: string): Promise<Change[] | undefined>;
diffWith(uri: URI, ref: string): Promise<Change[] | undefined>;
diffIndexWithHEADShortStats(uri: URI): Promise<CommitShortStat | undefined>;
fetch(uri: URI, remote?: string, ref?: string, depth?: number): Promise<void>;
getMergeBase(uri: URI, ref1: string, ref2: string): Promise<string | undefined>;
}
Expand Down
2 changes: 2 additions & 0 deletions src/platform/git/vscode/git.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,12 @@ export interface Repository {
diff(cached?: boolean): Promise<string>;
diffWithHEAD(): Promise<Change[]>;
diffWithHEAD(path: string): Promise<string>;
diffWithHEADShortStats(path?: string): Promise<CommitShortStat>;
diffWith(ref: string): Promise<Change[]>;
diffWith(ref: string, path: string): Promise<string>;
diffIndexWithHEAD(): Promise<Change[]>;
diffIndexWithHEAD(path: string): Promise<string>;
diffIndexWithHEADShortStats(path?: string): Promise<CommitShortStat>;
diffIndexWith(ref: string): Promise<Change[]>;
diffIndexWith(ref: string, path: string): Promise<string>;
diffBlobs(object1: string, object2: string): Promise<string>;
Expand Down
Loading
Loading