Skip to content

Commit 67fb6bc

Browse files
authored
Copilot CLI - publish statistics for session using git worktree (#1874)
* Copilot CLI - publish statistics for session using git worktree * Refactor things to stage changes * Fix test * Pull request feedback * Fix test * Another attempt to fix tests
1 parent c2c693d commit 67fb6bc

File tree

13 files changed

+100
-39
lines changed

13 files changed

+100
-39
lines changed

src/extension/agents/copilotcli/node/copilotCli.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULT_CLI_MODEL = 'claude-sonnet-4';
2323
export interface CopilotCLISessionOptions {
2424
addPermissionHandler(handler: SessionOptions['requestPermission']): IDisposable;
2525
toSessionOptions(): SessionOptions;
26+
isolationEnabled: boolean;
2627
}
2728

2829
export interface ICopilotCLIModels {
@@ -171,7 +172,8 @@ export class CopilotCLISessionOptionsService implements ICopilotCLISessionOption
171172
}
172173
});
173174
},
174-
toSessionOptions: () => allOptions
175+
toSessionOptions: () => allOptions,
176+
isolationEnabled: false
175177
} satisfies CopilotCLISessionOptions;
176178
}
177179
private async getWorkspaceFolderPath() {

src/extension/agents/copilotcli/node/copilotcliSession.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

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

204+
if (this._options.isolationEnabled) {
205+
// When isolation is enabled and we are using a git workspace, stage
206+
// all changes in the working directory when the session is completed
207+
const workingDirectory = this._options.toSessionOptions().workingDirectory;
208+
if (workingDirectory) {
209+
await this.gitService.add(Uri.file(workingDirectory), []);
210+
this.logService.trace(`[CopilotCLISession] Staged all changes in working directory ${workingDirectory}`);
211+
}
212+
}
213+
202214
this._status = ChatSessionStatus.Completed;
203215
this._statusChange.fire(this._status);
204216
} catch (error) {

src/extension/agents/copilotcli/node/copilotcliSessionService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export interface ICopilotCLISessionService {
4646

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

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

186-
public async createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, token: CancellationToken): Promise<CopilotCLISession> {
186+
public async createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, isolationEnabled: boolean | undefined, token: CancellationToken): Promise<CopilotCLISession> {
187187
const sessionDisposables = this._register(new DisposableStore());
188188
try {
189189
const options = await raceCancellationError(this.optionsService.createOptions({
190190
model: model as unknown as ModelMetadata['model'],
191191
workingDirectory
192192
}), token);
193+
options.isolationEnabled = isolationEnabled === true;
193194
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
194195
const sdkSession = await sessionManager.createSession(options.toSessionOptions());
195196
const chatMessages = await sdkSession.getChatContextMessages();

src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { DisposableStore, IDisposable } from '../../../../../util/vs/base/common
1212
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
1313
import { CancellationTokenSource, ChatSessionStatus, EventEmitter } from '../../../../../vscodeTypes';
1414
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
15-
import { ICopilotCLISDK, ICopilotCLISessionOptionsService } from '../copilotCli';
15+
import { CopilotCLISessionOptions, ICopilotCLISDK, ICopilotCLISessionOptionsService } from '../copilotCli';
1616
import { ICopilotCLISession } from '../copilotcliSession';
1717
import { CopilotCLISessionService } from '../copilotcliSessionService';
1818

@@ -84,8 +84,9 @@ describe('CopilotCLISessionService', () => {
8484
createOptions: vi.fn(async (opts: any) => {
8585
return {
8686
addPermissionHandler: () => ({ dispose() { /* noop */ } }),
87-
toSessionOptions: () => ({ ...opts, requestPermission: async () => ({ kind: 'denied-interactively-by-user' }) })
88-
};
87+
toSessionOptions: () => ({ ...opts, requestPermission: async () => ({ kind: 'denied-interactively-by-user' }) }),
88+
isolationEnabled: false
89+
} satisfies CopilotCLISessionOptions;
8990
}),
9091
};
9192
const sdk = {
@@ -137,15 +138,15 @@ describe('CopilotCLISessionService', () => {
137138

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

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

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

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

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

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

187188
describe('CopilotCLISessionService.deleteSession', () => {
188189
it('disposes active wrapper, removes from manager and fires change event', async () => {
189-
const session = await service.createSession('to delete', undefined, undefined, createToken().token);
190+
const session = await service.createSession('to delete', undefined, undefined, undefined, createToken().token);
190191
const id = session!.sessionId;
191192
let fired = false;
192193
disposables.add(service.onDidChangeSessions(() => { fired = true; }));
@@ -214,7 +215,7 @@ describe('CopilotCLISessionService', () => {
214215
describe('CopilotCLISessionService.auto disposal timeout', () => {
215216
it.skip('disposes session after completion timeout and aborts underlying sdk session', async () => {
216217
vi.useFakeTimers();
217-
const session = await service.createSession('will timeout', undefined, undefined, createToken().token);
218+
const session = await service.createSession('will timeout', undefined, undefined, undefined, createToken().token);
218219

219220
vi.advanceTimersByTime(31000);
220221
await Promise.resolve(); // allow any pending promises to run

src/extension/agents/copilotcli/node/test/copilotcliSession.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { CopilotCLISessionOptions, ICopilotCLISessionOptionsService } from '../c
1919
import { CopilotCLISession } from '../copilotcliSession';
2020
import { CopilotCLIToolNames } from '../copilotcliToolInvocationFormatter';
2121
import { PermissionRequest } from '../permissionHelpers';
22+
import { IGitService } from '../../../../../platform/git/common/gitService';
2223

2324
// Minimal shapes for types coming from the Copilot SDK we interact with
2425
interface MockSdkEventHandler { (payload: unknown): void }
@@ -77,7 +78,8 @@ function createSessionOptionsService() {
7778
permissionHandler = h;
7879
return { dispose: () => { permissionHandler = undefined; } };
7980
},
80-
toSessionOptions: () => allOptions
81+
toSessionOptions: () => allOptions,
82+
isolationEnabled: false
8183
} satisfies CopilotCLISessionOptions;
8284
}
8385
};
@@ -104,13 +106,15 @@ describe('CopilotCLISession', () => {
104106
let sdkSession: MockSdkSession;
105107
let workspaceService: IWorkspaceService;
106108
let logger: ILogService;
109+
let gitService: IGitService;
107110
let sessionOptionsService: ICopilotCLISessionOptionsService;
108111
let sessionOptions: CopilotCLISessionOptions;
109112

110113
beforeEach(async () => {
111114
const services = disposables.add(createExtensionUnitTestingServices());
112115
const accessor = services.createTestingAccessor();
113116
logger = accessor.get(ILogService);
117+
gitService = accessor.get(IGitService);
114118

115119
sdkSession = new MockSdkSession();
116120
sessionOptionsService = createSessionOptionsService();
@@ -128,6 +132,7 @@ describe('CopilotCLISession', () => {
128132
return disposables.add(new CopilotCLISession(
129133
sessionOptions,
130134
sdkSession as unknown as Session,
135+
gitService,
131136
logger,
132137
workspaceService,
133138
sessionOptionsService,

src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
135135
private readonly worktreeManager: CopilotCLIWorktreeManager,
136136
@ICopilotCLISessionService private readonly copilotcliSessionService: ICopilotCLISessionService,
137137
@ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration,
138+
@IGitService private readonly gitService: IGitService,
138139
@IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService
139140
) {
140141
super();
@@ -154,36 +155,47 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
154155

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

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

162163
return diskSessions;
163164
}
164165

165-
private _toChatSessionItem(session: { id: string; label: string; timestamp: Date; status?: vscode.ChatSessionStatus }): vscode.ChatSessionItem {
166+
private async _toChatSessionItem(session: { id: string; label: string; timestamp: Date; status?: vscode.ChatSessionStatus }): Promise<vscode.ChatSessionItem> {
166167
const resource = SessionIdForCLI.getResource(session.id);
167-
const label = session.label || 'Copilot CLI';
168-
const worktreePath = this.worktreeManager.getWorktreeRelativePath(session.id);
168+
const worktreePath = this.worktreeManager.getWorktreePath(session.id);
169+
const worktreeRelativePath = this.worktreeManager.getWorktreeRelativePath(session.id);
170+
171+
const label = session.label ?? 'Copilot CLI';
172+
const tooltipLines = [`Copilot CLI session: ${label}`];
169173
let description: vscode.MarkdownString | undefined;
170-
if (worktreePath) {
171-
description = new vscode.MarkdownString(`$(git-merge) ${worktreePath}`);
174+
let statistics: { files: number; insertions: number; deletions: number } | undefined;
175+
176+
if (worktreePath && worktreeRelativePath) {
177+
// Description
178+
description = new vscode.MarkdownString(`$(list-tree) ${worktreeRelativePath}`);
172179
description.supportThemeIcons = true;
180+
181+
// Tooltip
182+
tooltipLines.push(`Worktree: ${worktreeRelativePath}`);
183+
184+
// Statistics
185+
statistics = await this.gitService.diffIndexWithHEADShortStats(Uri.file(worktreePath));
173186
}
174-
const tooltipLines = [`Copilot CLI session: ${label}`];
175-
if (worktreePath) {
176-
tooltipLines.push(`Worktree: ${worktreePath}`);
177-
}
187+
178188
const status = session.status ?? vscode.ChatSessionStatus.Completed;
189+
179190
return {
180191
resource,
181192
label,
182193
description,
183194
tooltip: tooltipLines.join('\n'),
184195
timing: { startTime: session.timestamp.getTime() },
196+
statistics,
185197
status
186-
};
198+
} satisfies vscode.ChatSessionItem;
187199
}
188200

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

395+
const isolationEnabled = this.worktreeManager.getIsolationPreference(id);
396+
383397
const session = chatSessionContext.isUntitled ?
384-
await this.sessionService.createSession(prompt, modelId, workingDirectory, token) :
398+
await this.sessionService.createSession(prompt, modelId, workingDirectory, isolationEnabled, token) :
385399
await this.sessionService.getSession(id, undefined, workingDirectory, false, token);
386400

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

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

483497
await this.commandExecutionService.executeCommand('vscode.open', SessionIdForCLI.getResource(session.sessionId));
484498
await this.commandExecutionService.executeCommand('workbench.action.chat.submit', { inputValue: requestPrompt });

src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ class FakeWorktreeManager extends mock<CopilotCLIWorktreeManager>() {
6060
override createWorktree = vi.fn(async () => undefined);
6161
override storeWorktreePath = vi.fn(async () => { });
6262
override getWorktreePath = vi.fn((_id: string) => undefined);
63-
override getIsolationPreference = vi.fn(() => true);
63+
override getIsolationPreference = vi.fn(() => false);
6464
}
6565

66-
interface CreateSessionArgs { prompt: string | undefined; modelId: string | undefined; workingDirectory: string | undefined }
66+
interface CreateSessionArgs { prompt: string | undefined; modelId: string | undefined; workingDirectory: string | undefined; isolationEnabled: boolean | undefined }
6767

6868
class FakeCopilotCLISession implements ICopilotCLISession {
6969
public sessionId: string;
@@ -88,8 +88,8 @@ class FakeCopilotCLISession implements ICopilotCLISession {
8888
class FakeSessionService extends DisposableStore implements ICopilotCLISessionService {
8989
_serviceBrand: undefined;
9090
public createdArgs: CreateSessionArgs | undefined;
91-
public createSession = vi.fn(async (prompt: string | undefined, modelId: string | undefined, workingDirectory: string | undefined) => {
92-
this.createdArgs = { prompt, modelId, workingDirectory };
91+
public createSession = vi.fn(async (prompt: string | undefined, modelId: string | undefined, workingDirectory: string | undefined, isolationEnabled: boolean | undefined) => {
92+
this.createdArgs = { prompt, modelId, workingDirectory, isolationEnabled };
9393
const s = new FakeCopilotCLISession('new-session-id');
9494
return s as unknown as ICopilotCLISession;
9595
});
@@ -285,7 +285,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
285285
await participant.createHandler()(request, context, stream, token);
286286

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

306306
const expectedPrompt = 'Push that\n**Summary**\nprecomputed history';
307-
expect(sessionService.createSession).toHaveBeenCalledWith(expectedPrompt, undefined, undefined, token);
307+
expect(sessionService.createSession).toHaveBeenCalledWith(expectedPrompt, undefined, undefined, undefined, token);
308308
expect(summarySpy).not.toHaveBeenCalled();
309309
expect(execSpy).toHaveBeenCalledTimes(2);
310310
expect(execSpy.mock.calls[0].at(0)).toBe('vscode.open');

src/extension/prompt/node/test/repoInfoTelemetry.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ suite('RepoInfoTelemetry', () => {
8282
log: vi.fn(),
8383
diffBetween: vi.fn(),
8484
diffWith: vi.fn(),
85+
diffIndexWithHEADShortStats: vi.fn(),
8586
fetch: vi.fn(),
8687
getMergeBase: vi.fn(),
88+
add: vi.fn(),
8789
dispose: vi.fn()
8890
};
8991
services.define(IGitService, mockGitService);

src/platform/git/common/gitService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Event } from '../../../util/vs/base/common/event';
99
import { IObservable } from '../../../util/vs/base/common/observableInternal';
1010
import { equalsIgnoreCase } from '../../../util/vs/base/common/strings';
1111
import { URI } from '../../../util/vs/base/common/uri';
12-
import { Change, Commit, LogOptions } from '../vscode/git';
12+
import { Change, Commit, CommitShortStat, LogOptions } from '../vscode/git';
1313

1414
export interface RepoContext {
1515
readonly rootUri: URI;
@@ -52,9 +52,11 @@ export interface IGitService extends IDisposable {
5252
getRepository(uri: URI): Promise<RepoContext | undefined>;
5353
getRepositoryFetchUrls(uri: URI): Promise<Pick<RepoContext, 'rootUri' | 'remoteFetchUrls'> | undefined>;
5454
initialize(): Promise<void>;
55+
add(uri: URI, paths: string[]): Promise<void>;
5556
log(uri: URI, options?: LogOptions): Promise<Commit[] | undefined>;
5657
diffBetween(uri: URI, ref1: string, ref2: string): Promise<Change[] | undefined>;
5758
diffWith(uri: URI, ref: string): Promise<Change[] | undefined>;
59+
diffIndexWithHEADShortStats(uri: URI): Promise<CommitShortStat | undefined>;
5860
fetch(uri: URI, remote?: string, ref?: string, depth?: number): Promise<void>;
5961
getMergeBase(uri: URI, ref1: string, ref2: string): Promise<string | undefined>;
6062
}

src/platform/git/vscode/git.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,12 @@ export interface Repository {
233233
diff(cached?: boolean): Promise<string>;
234234
diffWithHEAD(): Promise<Change[]>;
235235
diffWithHEAD(path: string): Promise<string>;
236+
diffWithHEADShortStats(path?: string): Promise<CommitShortStat>;
236237
diffWith(ref: string): Promise<Change[]>;
237238
diffWith(ref: string, path: string): Promise<string>;
238239
diffIndexWithHEAD(): Promise<Change[]>;
239240
diffIndexWithHEAD(path: string): Promise<string>;
241+
diffIndexWithHEADShortStats(path?: string): Promise<CommitShortStat>;
240242
diffIndexWith(ref: string): Promise<Change[]>;
241243
diffIndexWith(ref: string, path: string): Promise<string>;
242244
diffBlobs(object1: string, object2: string): Promise<string>;

0 commit comments

Comments
 (0)