Skip to content

Commit 319b98d

Browse files
committed
Update Agent Host session config flow
(Written by Copilot)
1 parent 0bf615a commit 319b98d

16 files changed

Lines changed: 311 additions & 122 deletions

File tree

src/vs/platform/agentHost/common/agentService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export interface IAgentCreateSessionConfig {
104104
readonly fork?: { readonly session: URI; readonly turnIndex: number };
105105
}
106106

107+
export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint';
108+
107109
export interface IAgentResolveSessionConfigParams {
108110
readonly provider?: AgentProvider;
109111
readonly workingDirectory?: URI;

src/vs/platform/agentHost/node/copilot/copilotAgent.ts

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Limiter, SequencerByKey } from '../../../../base/common/async.js';
1010
import { Emitter } from '../../../../base/common/event.js';
1111
import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
1212
import { FileAccess } from '../../../../base/common/network.js';
13-
import { delimiter, dirname } from '../../../../base/common/path.js';
13+
import { basename, delimiter, dirname } from '../../../../base/common/path.js';
1414
import { URI } from '../../../../base/common/uri.js';
1515
import { generateUuid } from '../../../../base/common/uuid.js';
1616
import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginParsers.js';
@@ -19,7 +19,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati
1919
import { ILogService } from '../../../log/common/log.js';
2020
import { localize } from '../../../../nls.js';
2121
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
22-
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
22+
import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
2323
import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
2424
import { ISessionDataService } from '../../common/sessionDataService.js';
2525
import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js';
@@ -38,6 +38,18 @@ interface ICreatedWorktree {
3838
readonly worktree: URI;
3939
}
4040

41+
export function getCopilotWorktreesRoot(repositoryRoot: URI): URI {
42+
return URI.joinPath(repositoryRoot, '..', `${basename(repositoryRoot.fsPath)}.worktrees`);
43+
}
44+
45+
export function getCopilotWorktreeName(branchName: string): string {
46+
return branchName.replace(/\//g, '-');
47+
}
48+
49+
export function getCopilotWorktreeBranchName(sessionId: string, branchNameHint: string | undefined): string {
50+
return `agents/${branchNameHint ? `${branchNameHint}-${sessionId.substring(0, 8)}` : sessionId}`;
51+
}
52+
4153
/**
4254
* Agent provider backed by the Copilot SDK {@link CopilotClient}.
4355
*/
@@ -288,40 +300,40 @@ export class CopilotAgent extends Disposable implements IAgent {
288300

289301
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
290302
const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined;
291-
const targetValue = params.config?.target === 'folder' || params.config?.target === 'worktree'
292-
? params.config.target
303+
const isolationValue = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree'
304+
? params.config.isolation
293305
: gitInfo ? 'worktree' : 'folder';
294306

295-
const values: Record<string, string> = { target: targetValue };
307+
const values: Record<string, string> = { isolation: isolationValue };
296308
if (gitInfo) {
297-
values.branch = typeof params.config?.branch === 'string' && targetValue === 'worktree'
309+
values.branch = typeof params.config?.branch === 'string' && isolationValue === 'worktree'
298310
? params.config.branch
299311
: gitInfo.currentBranch;
300312
}
301313

302314
const properties: IResolveSessionConfigResult['schema']['properties'] = {
303-
target: {
315+
isolation: {
304316
type: 'string',
305-
title: localize('agentHost.sessionConfig.target', "Target"),
306-
description: localize('agentHost.sessionConfig.targetDescription', "Where the agent should make changes"),
317+
title: localize('agentHost.sessionConfig.isolation', "Isolation"),
318+
description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"),
307319
enum: gitInfo ? ['folder', 'worktree'] : ['folder'],
308-
enumLabels: gitInfo ? [localize('agentHost.sessionConfig.target.folder', "Folder"), localize('agentHost.sessionConfig.target.worktree', "Worktree")] : [localize('agentHost.sessionConfig.target.folder', "Folder")],
309-
enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.target.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.target.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.target.folderDescription', "Work directly in the folder")],
320+
enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")],
321+
enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")],
310322
enumIcons: gitInfo ? ['folder', 'worktree'] : ['folder'],
311323
default: gitInfo ? 'worktree' : 'folder',
312324
readOnly: !gitInfo,
313325
},
314326
};
315327

316328
if (gitInfo) {
317-
const branchReadOnly = targetValue === 'folder';
329+
const branchReadOnly = isolationValue === 'folder';
318330
properties.branch = {
319331
type: 'string',
320332
title: localize('agentHost.sessionConfig.branch', "Branch"),
321333
description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"),
322-
enum: branchReadOnly ? [gitInfo.currentBranch] : gitInfo.recentBranches,
323-
enumLabels: branchReadOnly ? [gitInfo.currentBranch] : gitInfo.recentBranches,
324-
enumIcons: branchReadOnly ? ['git-branch'] : gitInfo.recentBranches.map(() => 'git-branch'),
334+
enum: [gitInfo.currentBranch],
335+
enumLabels: [gitInfo.currentBranch],
336+
enumIcons: ['git-branch'],
325337
default: gitInfo.currentBranch,
326338
enumDynamic: !branchReadOnly,
327339
readOnly: branchReadOnly,
@@ -608,26 +620,21 @@ export class CopilotAgent extends Disposable implements IAgent {
608620
return agentSession;
609621
}
610622

611-
private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; recentBranches: string[] } | undefined> {
623+
private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string } | undefined> {
612624
if (!await this._gitService.isInsideWorkTree(workingDirectory)) {
613625
return undefined;
614626
}
615627

616628
const currentBranch = await this._gitService.getCurrentBranch(workingDirectory) ?? 'HEAD';
617-
const recentBranches = await this._getBranches(workingDirectory);
618-
return { currentBranch, recentBranches: this._prependUnique(currentBranch, recentBranches) };
629+
return { currentBranch };
619630
}
620631

621632
private async _getBranches(workingDirectory: URI, query?: string): Promise<string[]> {
622633
return this._gitService.getBranches(workingDirectory, { query, limit: CopilotAgent._BRANCH_COMPLETION_LIMIT });
623634
}
624635

625-
private _prependUnique(value: string, values: readonly string[]): string[] {
626-
return [value, ...values.filter(candidate => candidate !== value)];
627-
}
628-
629636
private async _resolveSessionWorkingDirectory(config: IAgentCreateSessionConfig | undefined, sessionId: string): Promise<URI | undefined> {
630-
if (config?.config?.target !== 'worktree' || !config.workingDirectory || typeof config.config.branch !== 'string') {
637+
if (config?.config?.isolation !== 'worktree' || !config.workingDirectory || typeof config.config.branch !== 'string') {
631638
return config?.workingDirectory;
632639
}
633640

@@ -636,10 +643,12 @@ export class CopilotAgent extends Disposable implements IAgent {
636643
return config.workingDirectory;
637644
}
638645

639-
const worktreesRoot = URI.joinPath(repositoryRoot, '..', '.copilot-worktrees');
640-
const worktree = URI.joinPath(worktreesRoot, sessionId);
646+
const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot);
647+
const branchNameHint = config.config[AgentHostSessionConfigBranchNameHintKey];
648+
const branchName = getCopilotWorktreeBranchName(sessionId, branchNameHint);
649+
const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName));
641650
await fs.mkdir(worktreesRoot.fsPath, { recursive: true });
642-
await this._gitService.addWorktree(repositoryRoot, worktree, `copilot/${sessionId}`, config.config.branch);
651+
await this._gitService.addWorktree(repositoryRoot, worktree, branchName, config.config.branch);
643652
this._createdWorktrees.set(sessionId, { repositoryRoot, worktree });
644653
return worktree;
645654
}

src/vs/platform/agentHost/test/node/agentService.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ suite('AgentService (node dispatcher)', () => {
224224
test('createSession stores live session config', async () => {
225225
service.registerProvider(copilotAgent);
226226

227-
const config = { target: 'worktree', branch: 'feature/config' };
227+
const config = { isolation: 'worktree', branch: 'feature/config' };
228228
const session = await service.createSession({ provider: 'copilot', config });
229229

230230
assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import assert from 'assert';
7+
import { URI } from '../../../../base/common/uri.js';
8+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
9+
import { getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js';
10+
11+
suite('CopilotAgent', () => {
12+
ensureNoDisposablesAreLeakedInTestSuite();
13+
14+
test('uses the Copilot CLI sibling worktrees root convention', () => {
15+
assert.strictEqual(
16+
getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath,
17+
URI.file('/Users/me/src/vscode.worktrees').fsPath,
18+
);
19+
});
20+
21+
test('uses Agents-window Copilot CLI branch prefix', () => {
22+
assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'add-agent-host-config'), 'agents/add-agent-host-config-12345678');
23+
assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', undefined), 'agents/12345678-aaaa-bbbb-cccc-123456789abc');
24+
});
25+
26+
test('uses Git extension branch-derived worktree folder names', () => {
27+
assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'agents-add-agent-host-config-12345678');
28+
});
29+
30+
test('keeps hinted branch names short', () => {
31+
assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'a'.repeat(48)).length, 'agents/'.length + 48 + '-12345678'.length);
32+
});
33+
});

src/vs/platform/agentHost/test/node/mockAgent.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -226,16 +226,16 @@ export class ScriptedMockAgent implements IAgent {
226226
}
227227

228228
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
229-
const target = params.config?.target === 'folder' || params.config?.target === 'worktree' ? params.config.target : 'worktree';
230-
const branch = target === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main';
229+
const isolation = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation : 'worktree';
230+
const branch = isolation === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main';
231231
return {
232232
ready: true,
233233
schema: {
234234
type: 'object',
235235
properties: {
236-
target: {
236+
isolation: {
237237
type: 'string',
238-
title: 'Target',
238+
title: 'Isolation',
239239
description: 'Where the mock agent should make changes',
240240
enum: ['folder', 'worktree'],
241241
enumLabels: ['Folder', 'Worktree'],
@@ -245,16 +245,16 @@ export class ScriptedMockAgent implements IAgent {
245245
type: 'string',
246246
title: 'Branch',
247247
description: 'Base branch to work from',
248-
enum: target === 'folder' ? ['main'] : ['main', 'feature/config', 'release'],
249-
enumLabels: target === 'folder' ? ['main'] : ['main', 'feature/config', 'release'],
250-
enumIcons: target === 'folder' ? ['git-branch'] : ['git-branch', 'git-branch', 'git-branch'],
248+
enum: ['main'],
249+
enumLabels: ['main'],
250+
enumIcons: ['git-branch'],
251251
default: 'main',
252-
enumDynamic: target === 'worktree',
253-
readOnly: target === 'folder',
252+
enumDynamic: isolation === 'worktree',
253+
readOnly: isolation === 'folder',
254254
},
255255
},
256256
},
257-
values: { target, branch },
257+
values: { isolation, branch },
258258
};
259259
}
260260

src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,21 +55,22 @@ suite('Protocol WebSocket - Session Config', function () {
5555

5656
assert.deepStrictEqual({ ready: initial.ready, values: initial.values }, {
5757
ready: true,
58-
values: { target: 'worktree', branch: 'main' },
58+
values: { isolation: 'worktree', branch: 'main' },
5959
});
60-
assert.deepStrictEqual(Object.keys(initial.schema.properties), ['target', 'branch']);
60+
assert.deepStrictEqual(Object.keys(initial.schema.properties), ['isolation', 'branch']);
61+
assert.deepStrictEqual(initial.schema.properties.branch.enum, ['main']);
6162
assert.strictEqual(initial.schema.properties.branch.enumDynamic, true);
6263
assert.strictEqual(initial.schema.properties.branch.readOnly, false);
6364

6465
const folder = await client.call<IResolveSessionConfigResult>('resolveSessionConfig', {
6566
provider: 'mock',
6667
workingDirectory,
67-
config: { target: 'folder', branch: 'feature/config' },
68+
config: { isolation: 'folder', branch: 'feature/config' },
6869
});
6970

7071
assert.deepStrictEqual({ ready: folder.ready, values: folder.values }, {
7172
ready: true,
72-
values: { target: 'folder', branch: 'main' },
73+
values: { isolation: 'folder', branch: 'main' },
7374
});
7475
assert.strictEqual(folder.schema.properties.branch.enumDynamic, false);
7576
assert.strictEqual(folder.schema.properties.branch.readOnly, true);
@@ -81,7 +82,7 @@ suite('Protocol WebSocket - Session Config', function () {
8182
const result = await client.call<ISessionConfigCompletionsResult>('sessionConfigCompletions', {
8283
provider: 'mock',
8384
workingDirectory: URI.file('/mock/workspace').toString(),
84-
config: { target: 'worktree' },
85+
config: { isolation: 'worktree' },
8586
property: 'branch',
8687
query: 'feat',
8788
});
@@ -94,7 +95,7 @@ suite('Protocol WebSocket - Session Config', function () {
9495
test('createSession stores config schema and values on session state', async function () {
9596
this.timeout(10_000);
9697

97-
const config = { target: 'worktree', branch: 'feature/config' };
98+
const config = { isolation: 'worktree', branch: 'feature/config' };
9899
await client.call('createSession', {
99100
session: nextSessionUri(),
100101
provider: 'mock',
@@ -111,7 +112,7 @@ suite('Protocol WebSocket - Session Config', function () {
111112
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: notification.summary.resource });
112113
const state = snapshot.snapshot.state as ISessionState;
113114
assert.deepStrictEqual(state.config?.values, config);
114-
assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}), ['target', 'branch']);
115+
assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}), ['isolation', 'branch']);
115116
});
116117

117118
test('session/configChanged merges config values into session state', async function () {
@@ -120,7 +121,7 @@ suite('Protocol WebSocket - Session Config', function () {
120121
await client.call('createSession', {
121122
session: nextSessionUri(),
122123
provider: 'mock',
123-
config: { target: 'folder', branch: 'main' },
124+
config: { isolation: 'folder', branch: 'main' },
124125
});
125126

126127
const notif = await client.waitForNotification(n =>
@@ -144,6 +145,6 @@ suite('Protocol WebSocket - Session Config', function () {
144145

145146
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: session });
146147
const state = snapshot.snapshot.state as ISessionState;
147-
assert.deepStrictEqual(state.config?.values, { target: 'folder', branch: 'release' });
148+
assert.deepStrictEqual(state.config?.values, { isolation: 'folder', branch: 'release' });
148149
});
149150
});

src/vs/sessions/common/agentHostSessionWorkspace.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject
2929
if (project) {
3030
const repositoryWorkingDirectory = extUri.isEqual(workingDirectory, project.uri) ? undefined : workingDirectory;
3131
return {
32-
label: project.displayName,
32+
label: options.providerLabel ? `${project.displayName} [${options.providerLabel}]` : project.displayName,
3333
icon: Codicon.repo,
34-
repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: options.providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }],
34+
repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }],
3535
requiresWorkspaceTrust: options.requiresWorkspaceTrust,
3636
};
3737
}
@@ -44,7 +44,7 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject
4444
return {
4545
label: options.providerLabel ? `${folderName} [${options.providerLabel}]` : folderName,
4646
icon: options.fallbackIcon,
47-
repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: options.providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }],
47+
repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }],
4848
requiresWorkspaceTrust: options.requiresWorkspaceTrust,
4949
};
5050
}

src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act
1919
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
2020
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
2121
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
22+
import { AgentHostSessionConfigBranchNameHintKey } from '../../../../platform/agentHost/common/agentService.js';
2223
import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js';
2324
import { IQuickInputService, type IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
2425
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
@@ -142,6 +143,9 @@ class AgentHostSessionConfigPicker extends Disposable {
142143
}
143144

144145
for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) {
146+
if (property === AgentHostSessionConfigBranchNameHintKey) {
147+
continue;
148+
}
145149
const value = resolvedConfig.values[property] ?? schema.default;
146150
const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot'));
147151
const trigger = renderPickerTrigger(slot, !!schema.readOnly, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger));

0 commit comments

Comments
 (0)