豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 1ee7baf

Browse files
Refactor Copilot CLI session request handling to ensure commits happen on last request (#4467)
* Refactor Copilot CLI session request handling to ensure commits happen on last request * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent c28e751 commit 1ee7baf

File tree

2 files changed

+110
-27
lines changed

2 files changed

+110
-27
lines changed

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

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,11 +1050,23 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
10501050
/**
10511051
* Map to track pending requests for untitled sessions.
10521052
* Key = Untitled Session Id
1053-
* Vlaue = Map of Request Id to the Promise of the request being handled
1053+
* Value = Map of Request Id to the Promise of the request being handled
10541054
* So if we have multiple requests (can happen when steering) for the same untitled session.
10551055
*/
10561056
private readonly pendingRequestsForUntitledSessions = new Map<string, Map<string, Promise<vscode.ChatResult | void>>>();
1057-
private readonly pendingCommitWorktreeChangesBySession = new Map<string, Promise<void>>();
1057+
1058+
/**
1059+
* Tracks in-flight requests per session so we can coordinate worktree
1060+
* commit / PR handling and cleanup.
1061+
*
1062+
* We generally cannot have parallel requests for the same session, but when
1063+
* steering is involved there can be multiple requests in flight for a
1064+
* single session (the original request continues running while steering
1065+
* requests are processed). This map records all active requests for each
1066+
* session so that any worktree-related actions are deferred until the last
1067+
* in-flight request for that session has completed.
1068+
*/
1069+
private readonly pendingRequestBySession = new Map<string, Set<vscode.ChatRequest>>();
10581070

10591071
/**
10601072
* Outer request handler that supports *yielding* for session steering.
@@ -1065,7 +1077,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
10651077
* previous request (status is `InProgress` or `NeedsInput`).
10661078
* 2. VS Code signals this by setting `context.yieldRequested = true` on the
10671079
* *previous* request's context object.
1068-
* 3. This handler polls `context.yieldRequested` every 500 ms. Once detected
1080+
* 3. This handler polls `context.yieldRequested` every 100 ms. Once detected
10691081
* the outer `Promise.race` resolves, returning control to VS Code so it
10701082
* can dispatch the new (steering) request.
10711083
* 4. Crucially, the inner `handleRequestImpl` promise is **not** cancelled
@@ -1106,7 +1118,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
11061118
}
11071119
}
11081120

1109-
private sendTelemetryForHandleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, result: vscode.ChatResult | void, error: unknown): void {
1121+
private sendTelemetryForHandleRequest(request: vscode.ChatRequest, context: vscode.ChatContext): void {
11101122
const { chatSessionContext } = context;
11111123
const hasChatSessionItem = String(!!chatSessionContext?.chatSessionItem);
11121124
let isUntitled = String(chatSessionContext?.isUntitled);
@@ -1138,11 +1150,12 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
11381150
let { chatSessionContext } = context;
11391151
const disposables = new DisposableStore();
11401152
let sessionId: string | undefined = undefined;
1153+
let sdkSessionId: string | undefined = undefined;
11411154
try {
11421155

11431156
const initialOptions = chatSessionContext?.initialSessionOptions;
11441157
if (initialOptions && chatSessionContext) {
1145-
if (initialOptions && initialOptions.length > 0) {
1158+
if (initialOptions.length > 0) {
11461159
const sessionResource = chatSessionContext.chatSessionItem.resource;
11471160
const sessionId = SessionIdForCLI.parse(sessionResource);
11481161
for (const opt of initialOptions) {
@@ -1186,7 +1199,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
11861199
}
11871200
}
11881201

1189-
this.sendTelemetryForHandleRequest(request, context, undefined, undefined);
1202+
this.sendTelemetryForHandleRequest(request, context);
11901203

11911204
const [authInfo,] = await Promise.all([this.copilotCLISDK.getAuthInfo().catch((ex) => this.logService.error(ex, 'Authorization failed')), this.lockRepoOptionForSession(context, token)]);
11921205
if (!authInfo) {
@@ -1242,7 +1255,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
12421255
}
12431256
return {};
12441257
}
1245-
1258+
sdkSessionId = session.object.sessionId;
12461259
this.copilotCLIAgents.trackSessionAgent(session.object.sessionId, agent?.name);
12471260
if (isUntitled && !this.useController) {
12481261
disposables.add(toDisposable(() => this.sessionItemProvider.untitledSessionIdMapping.delete(session.object.sessionId)));
@@ -1259,6 +1272,10 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
12591272
// No need to track the temproary workspace folders as its been persisted.
12601273
this.folderRepositoryManager.deleteUntitledSessionFolder(id);
12611274
}
1275+
const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set<vscode.ChatRequest>();
1276+
requestsForSession.add(request);
1277+
this.pendingRequestBySession.set(session.object.sessionId, requestsForSession);
1278+
12621279
// Check if we have context stored for this request (created in createCLISessionAndSubmitRequest, work around)
12631280
const contextForRequest = this.contextForRequest.get(session.object.sessionId);
12641281
this.contextForRequest.delete(session.object.sessionId);
@@ -1269,22 +1286,22 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
12691286
const { prompt, attachments } = contextForRequest;
12701287
this.contextForRequest.delete(session.object.sessionId);
12711288
await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);
1272-
await this.commitWorktreeChangesIfNeeded(session.object, token);
1289+
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
12731290
} else if (request.command && !request.prompt && !isUntitled) {
12741291
const input = (copilotCLICommands as readonly string[]).includes(request.command)
12751292
? { command: request.command as CopilotCLICommand }
12761293
: { prompt: `/${request.command}` };
12771294
await session.object.handleRequest(request, input, [], model, authInfo, token);
1278-
await this.commitWorktreeChangesIfNeeded(session.object, token);
1295+
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
12791296
} else if (request.prompt && Object.values(builtinSlashSCommands).includes(request.prompt)) {
12801297
await session.object.handleRequest(request, { prompt: request.prompt }, [], model, authInfo, token);
1281-
await this.commitWorktreeChangesIfNeeded(session.object, token);
1298+
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
12821299
} else {
12831300
// Construct the full prompt with references to be sent to CLI.
12841301
const plan = request.modeInstructions2 ? isCopilotCLIPlanAgent(request.modeInstructions2) : false;
12851302
const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token);
12861303
await session.object.handleRequest(request, { prompt, plan }, attachments, model, authInfo, token);
1287-
await this.commitWorktreeChangesIfNeeded(session.object, token);
1304+
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
12881305
}
12891306

12901307
if (isUntitled && !token.isCancellationRequested) {
@@ -1322,6 +1339,15 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
13221339
throw ex;
13231340
}
13241341
finally {
1342+
if (sdkSessionId) {
1343+
const requestsForSession = this.pendingRequestBySession.get(sdkSessionId);
1344+
if (requestsForSession) {
1345+
requestsForSession.delete(request);
1346+
if (requestsForSession.size === 0) {
1347+
this.pendingRequestBySession.delete(sdkSessionId);
1348+
}
1349+
}
1350+
}
13251351
if (chatSessionContext?.chatSessionItem.resource) {
13261352
this.sessionItemProvider.notifySessionsChange();
13271353
}
@@ -1408,17 +1434,21 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
14081434
}
14091435
}
14101436

1411-
private async commitWorktreeChangesIfNeeded(session: ICopilotCLISession, token: vscode.CancellationToken): Promise<void> {
1412-
if (token.isCancellationRequested) {
1437+
private async commitWorktreeChangesIfNeeded(request: vscode.ChatRequest, session: ICopilotCLISession, token: vscode.CancellationToken): Promise<void> {
1438+
const pendingRequests = this.pendingRequestBySession.get(session.sessionId);
1439+
if (pendingRequests && pendingRequests.size > 1) {
1440+
// We still have pending requests for this session, which means the user has done some steering.
1441+
// Wait for all requests to complete, the last request to complete will handle the commit.
1442+
pendingRequests.delete(request);
14131443
return;
14141444
}
14151445

1416-
const existingPromise = this.pendingCommitWorktreeChangesBySession.get(session.sessionId);
1417-
if (existingPromise) {
1418-
return existingPromise;
1446+
if (token.isCancellationRequested) {
1447+
pendingRequests?.delete(request);
1448+
return;
14191449
}
14201450

1421-
const commitPromise = (async () => {
1451+
try {
14221452
if (session.status === vscode.ChatSessionStatus.Completed) {
14231453
const workingDirectory = getWorkingDirectory(session.workspace);
14241454
if (isIsolationEnabled(session.workspace)) {
@@ -1434,14 +1464,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
14341464
}
14351465

14361466
await this.handlePullRequestCreated(session);
1437-
})().finally(() => {
1438-
if (this.pendingCommitWorktreeChangesBySession.get(session.sessionId) === commitPromise) {
1439-
this.pendingCommitWorktreeChangesBySession.delete(session.sessionId);
1440-
}
1441-
});
1442-
1443-
this.pendingCommitWorktreeChangesBySession.set(session.sessionId, commitPromise);
1444-
return commitPromise;
1467+
} finally {
1468+
pendingRequests?.delete(request);
1469+
}
14451470
}
14461471

14471472
private async handlePullRequestCreated(session: ICopilotCLISession): Promise<void> {
@@ -1698,7 +1723,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
16981723
// Now that we've delegated it to a session, we can get out of here.
16991724
// Else if the request takes say 10 minutes, the caller would be blocked for that long.
17001725
session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token)
1701-
.then(() => this.commitWorktreeChangesIfNeeded(session.object, token))
1726+
.then(() => this.commitWorktreeChangesIfNeeded(request, session.object, token))
17021727
.catch(error => {
17031728
this.logService.error(`Failed to handle CLI session request: ${error}`);
17041729
// Optionally: stream.error(error) to notify the user

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ class TestCopilotCLISession extends CopilotCLISession {
216216
public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; modelId: string | undefined; authInfo: NonNullable<SessionOptions['authInfo']>; token: vscode.CancellationToken }> = [];
217217
public static nextHandleRequestResult: Promise<void> | undefined;
218218
public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken }, input: CopilotCLISessionInput) => Promise<void>) | undefined;
219+
public static statusOverride?: vscode.ChatSessionStatus;
220+
override get status(): vscode.ChatSessionStatus | undefined {
221+
return TestCopilotCLISession.statusOverride;
222+
}
219223
override handleRequest(request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken }, input: CopilotCLISessionInput, attachments: Attachment[], modelId: string | undefined, authInfo: NonNullable<SessionOptions['authInfo']>, token: vscode.CancellationToken): Promise<void> {
220224
this.requests.push({ input, attachments, modelId, authInfo, token });
221225
if (TestCopilotCLISession.handleRequestHook) {
@@ -270,6 +274,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
270274
cliSessions.length = 0;
271275
TestCopilotCLISession.nextHandleRequestResult = undefined;
272276
TestCopilotCLISession.handleRequestHook = undefined;
277+
TestCopilotCLISession.statusOverride = undefined;
273278
// By default, simulate the command not being available so that
274279
// handleDelegationFromAnotherChat falls into its catch block and
275280
// calls handleRequest directly. The workaround tests override this.
@@ -541,6 +546,59 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
541546
await handlerPromise;
542547
});
543548

549+
it('defers worktree handleRequestCompleted until all steering requests complete', async () => {
550+
// Use an existing (non-untitled) session so both concurrent requests are guaranteed to
551+
// resolve to the same SDK session and share the same pendingRequestBySession entry.
552+
const sessionId = 'existing-worktree-session';
553+
const sdkSession = new MockCliSdkSession(sessionId, new Date());
554+
manager.sessions.set(sessionId, sdkSession);
555+
556+
const worktreeProperties = {
557+
autoCommit: true,
558+
baseCommit: 'deadbeef',
559+
branchName: 'test',
560+
repositoryPath: `${sep}repo`,
561+
worktreePath: `${sep}worktree`,
562+
version: 1
563+
} satisfies ChatSessionWorktreeProperties;
564+
// FolderRepositoryManagerImpl.getFolderRepository checks worktreeService.getWorktreeProperties(sessionId)
565+
// when the session ID is not an untitled ID.
566+
(worktree.getWorktreeProperties as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(worktreeProperties);
567+
// Simulate the session completing so the worktree commit path runs
568+
TestCopilotCLISession.statusOverride = vscode.ChatSessionStatus.Completed;
569+
570+
let resolveFirst!: () => void;
571+
const firstDeferred = new Promise<void>(resolve => { resolveFirst = resolve; });
572+
let resolveSecond!: () => void;
573+
const secondDeferred = new Promise<void>(resolve => { resolveSecond = resolve; });
574+
575+
TestCopilotCLISession.handleRequestHook = vi.fn((_request, input) => {
576+
if (input.prompt === 'First') { return firstDeferred; }
577+
return secondDeferred;
578+
});
579+
580+
const context = createChatContext(sessionId, false);
581+
const stream = new MockChatResponseStream();
582+
583+
const firstRequest = new TestChatRequest('First');
584+
const firstToken = disposables.add(new CancellationTokenSource()).token;
585+
const firstPromise = participant.createHandler()(firstRequest, context, stream, firstToken);
586+
587+
const secondRequest = new TestChatRequest('Second');
588+
const secondToken = disposables.add(new CancellationTokenSource()).token;
589+
const secondPromise = participant.createHandler()(secondRequest, context, stream, secondToken);
590+
591+
// Second (steering) request completes first — commit must NOT fire while first is still pending
592+
resolveSecond();
593+
await secondPromise;
594+
expect(worktree.handleRequestCompleted).not.toHaveBeenCalled();
595+
596+
// First request completes last — commit fires exactly once
597+
resolveFirst();
598+
await firstPromise;
599+
expect(worktree.handleRequestCompleted).toHaveBeenCalledTimes(1);
600+
});
601+
544602
it('defers untitled session swap while a steering request is still pending', async () => {
545603
(itemProvider.isNewSession as ReturnType<typeof vi.fn>).mockImplementation((sessionId: string) => sessionId.startsWith('untitled:'));
546604
let resolveFirstRequest!: () => void;
@@ -1290,7 +1348,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
12901348
);
12911349
expect(lockCalls.length).toBeGreaterThan(0);
12921350

1293-
// Verify unlock was called on failure
1351+
// Verify unlock was NOT called on failure (session creation failed but workspace was trusted)
12941352
const unlockCalls = allCalls.filter(
12951353
call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')
12961354
);

0 commit comments

Comments
 (0)