Skip to content

Commit 708e2da

Browse files
authored
Restore edits for background sessions (#2180)
1 parent 73f7610 commit 708e2da

19 files changed

+153
-85
lines changed

src/extension/agents/common/externalEditTracker.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { IDisposable } from '../../../util/vs/base/common/lifecycle';
1414
* externalEdit API to ensure proper tracking and attribution of file changes.
1515
*/
1616
export class ExternalEditTracker {
17-
private _ongoingEdits = new Map<string, { complete: () => void; onDidComplete: Thenable<void> }>();
17+
private _ongoingEdits = new Map<string, { complete: () => void; onDidComplete: Thenable<string> }>();
1818

1919
/**
2020
* Starts tracking an external edit operation.
@@ -65,12 +65,12 @@ export class ExternalEditTracker {
6565
* @param editKey Unique identifier for the edit operation to complete
6666
* @returns Promise that resolves when VS Code has finished tracking the edit
6767
*/
68-
public async completeEdit(editKey: string): Promise<void> {
68+
public async completeEdit(editKey: string): Promise<string | undefined> {
6969
const ongoingEdit = this._ongoingEdits.get(editKey);
7070
if (ongoingEdit) {
7171
this._ongoingEdits.delete(editKey);
7272
ongoingEdit.complete();
73-
await ongoingEdit.onDidComplete;
73+
return await ongoingEdit.onDidComplete;
7474
}
7575
}
7676
}

src/extension/agents/copilotcli/common/copilotCLITools.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { ChatPromptReference, ChatTerminalToolInvocationData, ExtendedChatR
99
import { isLocation } from '../../../../util/common/types';
1010
import { ResourceSet } from '../../../../util/vs/base/common/map';
1111
import { URI } from '../../../../util/vs/base/common/uri';
12-
import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponsePullRequestPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart, MarkdownString, Uri } from '../../../../vscodeTypes';
12+
import { ChatRequestTurn2, ChatResponseCodeblockUriPart, ChatResponseMarkdownPart, ChatResponsePullRequestPart, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart, MarkdownString, Uri } from '../../../../vscodeTypes';
1313
import { formatUriForFileWidget } from '../../../tools/common/toolUtils';
1414
import { extractChatPromptReferences, getFolderAttachmentPath } from './copilotCLIPrompt';
1515

@@ -283,12 +283,14 @@ function extractPRMetadata(content: string): { cleanedContent: string; prPart?:
283283
* Build chat history from SDK events for VS Code chat session
284284
* Converts SDKEvents into ChatRequestTurn2 and ChatResponseTurn2 objects
285285
*/
286-
export function buildChatHistoryFromEvents(events: readonly SessionEvent[]): (ChatRequestTurn2 | ChatResponseTurn2)[] {
286+
export function buildChatHistoryFromEvents(events: readonly SessionEvent[], getVSCodeRequestId?: (sdkRequestId: string) => { requestId: string; toolIdEditMap: Record<string, string> } | undefined): (ChatRequestTurn2 | ChatResponseTurn2)[] {
287287
const turns: (ChatRequestTurn2 | ChatResponseTurn2)[] = [];
288288
let currentResponseParts: ExtendedChatResponsePart[] = [];
289-
const pendingToolInvocations = new Map<string, ChatToolInvocationPart>();
289+
const pendingToolInvocations = new Map<string, [ChatToolInvocationPart, toolData: ToolCall]>();
290290

291+
let details: { requestId: string; toolIdEditMap: Record<string, string> } | undefined;
291292
for (const event of events) {
293+
details = getVSCodeRequestId?.(event.id) ?? details;
292294
switch (event.type) {
293295
case 'user.message': {
294296
// Flush any pending response parts before adding user message
@@ -338,7 +340,7 @@ export function buildChatHistoryFromEvents(events: readonly SessionEvent[]): (Ch
338340
range
339341
});
340342
});
341-
turns.push(new ChatRequestTurn2(stripReminders(event.data.content || ''), undefined, references, '', [], undefined));
343+
turns.push(new ChatRequestTurn2(stripReminders(event.data.content || ''), undefined, references, '', [], undefined, details?.requestId));
342344
break;
343345
}
344346
case 'assistant.message': {
@@ -367,9 +369,21 @@ export function buildChatHistoryFromEvents(events: readonly SessionEvent[]): (Ch
367369
break;
368370
}
369371
case 'tool.execution_complete': {
370-
const responsePart = processToolExecutionComplete(event, pendingToolInvocations);
371-
if (responsePart && !(responsePart instanceof ChatResponseThinkingProgressPart)) {
372-
currentResponseParts.push(responsePart);
372+
const [responsePart, toolCall] = processToolExecutionComplete(event, pendingToolInvocations) ?? [undefined, undefined];
373+
if (responsePart && toolCall && !(responsePart instanceof ChatResponseThinkingProgressPart)) {
374+
const editId = details?.toolIdEditMap ? details.toolIdEditMap[toolCall.toolCallId] : undefined;
375+
const editedUris = getAffectedUrisForEditTool(toolCall);
376+
if (isCopilotCliEditToolCall(toolCall) && editId && editedUris.length > 0) {
377+
for (const uri of editedUris) {
378+
currentResponseParts.push(new ChatResponseMarkdownPart('\n````\n'));
379+
currentResponseParts.push(new ChatResponseCodeblockUriPart(uri, true, editId));
380+
currentResponseParts.push(new ChatResponseMarkdownPart('\n````\n'));
381+
currentResponseParts.push(new ChatResponseTextEditPart(uri, []));
382+
currentResponseParts.push(new ChatResponseTextEditPart(uri, true));
383+
}
384+
} else {
385+
currentResponseParts.push(responsePart);
386+
}
373387
}
374388
break;
375389
}
@@ -393,27 +407,27 @@ function getRangeInPrompt(prompt: string, referencedName: string): [number, numb
393407
return undefined;
394408
}
395409

396-
export function processToolExecutionStart(event: ToolExecutionStartEvent, pendingToolInvocations: Map<string, ChatToolInvocationPart | ChatResponseThinkingProgressPart>): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined {
410+
export function processToolExecutionStart(event: ToolExecutionStartEvent, pendingToolInvocations: Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall]>): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined {
397411
const toolInvocation = createCopilotCLIToolInvocation(event.data as ToolCall);
398412
if (toolInvocation) {
399413
// Store pending invocation to update with result later
400-
pendingToolInvocations.set(event.data.toolCallId, toolInvocation);
414+
pendingToolInvocations.set(event.data.toolCallId, [toolInvocation, event.data as ToolCall]);
401415
}
402416
return toolInvocation;
403417
}
404418

405-
export function processToolExecutionComplete(event: ToolExecutionCompleteEvent, pendingToolInvocations: Map<string, ChatToolInvocationPart | ChatResponseThinkingProgressPart>): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined {
419+
export function processToolExecutionComplete(event: ToolExecutionCompleteEvent, pendingToolInvocations: Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall]>): [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall] | undefined {
406420
const invocation = pendingToolInvocations.get(event.data.toolCallId);
407421
pendingToolInvocations.delete(event.data.toolCallId);
408422

409-
if (invocation && invocation instanceof ChatToolInvocationPart) {
410-
invocation.isComplete = true;
411-
invocation.isError = !!event.data.error;
412-
invocation.invocationMessage = event.data.error?.message || invocation.invocationMessage;
423+
if (invocation && invocation[0] instanceof ChatToolInvocationPart) {
424+
invocation[0].isComplete = true;
425+
invocation[0].isError = !!event.data.error;
426+
invocation[0].invocationMessage = event.data.error?.message || invocation[0].invocationMessage;
413427
if (!event.data.success && (event.data.error?.code === 'rejected' || event.data.error?.code === 'denied')) {
414-
invocation.isConfirmed = false;
428+
invocation[0].isConfirmed = false;
415429
} else {
416-
invocation.isConfirmed = true;
430+
invocation[0].isConfirmed = true;
417431
}
418432
}
419433

@@ -423,7 +437,7 @@ export function processToolExecutionComplete(event: ToolExecutionCompleteEvent,
423437
/**
424438
* Creates a formatted tool invocation part for CopilotCLI tools
425439
*/
426-
export function createCopilotCLIToolInvocation(data: { toolCallId: string; toolName: string; arguments?: unknown }): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined {
440+
export function createCopilotCLIToolInvocation(data: { toolCallId: string; toolName: string; arguments?: unknown }, editId?: string): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined {
427441
if (!Object.hasOwn(ToolFriendlyNameAndHandlers, data.toolName)) {
428442
const invocation = new ChatToolInvocationPart(data.toolName ?? 'unknown', data.toolCallId ?? '', false);
429443
invocation.isConfirmed = false;
@@ -450,11 +464,11 @@ export function createCopilotCLIToolInvocation(data: { toolCallId: string; toolN
450464
invocation.isConfirmed = false;
451465
invocation.isComplete = false;
452466

453-
(formatter as Formatter)(invocation, toolCall);
467+
(formatter as Formatter)(invocation, toolCall, editId);
454468
return invocation;
455469
}
456470

457-
type Formatter = (invocation: ChatToolInvocationPart, toolCall: ToolCall) => void;
471+
type Formatter = (invocation: ChatToolInvocationPart, toolCall: ToolCall, editId?: string) => void;
458472
type ToolCallFor<T extends ToolCall['toolName']> = Extract<ToolCall, { toolName: T }>;
459473

460474
const ToolFriendlyNameAndHandlers: { [K in ToolCall['toolName']]: [string, (invocation: ChatToolInvocationPart, toolCall: ToolCallFor<K>) => void] } = {
@@ -513,7 +527,7 @@ function formatViewToolInvocation(invocation: ChatToolInvocationPart, toolCall:
513527
}
514528
}
515529

516-
function formatStrReplaceEditorInvocation(invocation: ChatToolInvocationPart, toolCall: StringReplaceEditorTool): void {
530+
function formatStrReplaceEditorInvocation(invocation: ChatToolInvocationPart, toolCall: StringReplaceEditorTool, editId?: string): void {
517531
if (!toolCall.arguments.path) {
518532
return;
519533
}
@@ -554,7 +568,7 @@ function formatUndoEdit(invocation: ChatToolInvocationPart, toolCall: UndoEditTo
554568
}
555569
}
556570

557-
function formatEditToolInvocation(invocation: ChatToolInvocationPart, toolCall: EditTool): void {
571+
function formatEditToolInvocation(invocation: ChatToolInvocationPart, toolCall: EditTool, editId?: string): void {
558572
const args = toolCall.arguments;
559573
const display = args.path ? formatUriForFileWidget(Uri.file(args.path)) : '';
560574

@@ -564,7 +578,7 @@ function formatEditToolInvocation(invocation: ChatToolInvocationPart, toolCall:
564578
}
565579

566580

567-
function formatCreateToolInvocation(invocation: ChatToolInvocationPart, toolCall: CreateTool): void {
581+
function formatCreateToolInvocation(invocation: ChatToolInvocationPart, toolCall: CreateTool, editId?: string): void {
568582
const args = toolCall.arguments;
569583
const display = args.path ? formatUriForFileWidget(Uri.file(args.path)) : '';
570584

src/extension/agents/copilotcli/common/test/copilotCLITools.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
isCopilotCliEditToolCall,
2121
processToolExecutionComplete,
2222
processToolExecutionStart,
23-
stripReminders
23+
stripReminders,
24+
ToolCall
2425
} from '../copilotCLITools';
2526

2627
// Helper to extract invocation message text independent of MarkdownString vs string
@@ -160,21 +161,21 @@ describe('CopilotCLITools', () => {
160161

161162
describe('process tool execution lifecycle', () => {
162163
it('marks tool invocation complete and confirmed on success', () => {
163-
const pending = new Map<string, ChatToolInvocationPart | ChatResponseThinkingProgressPart>();
164+
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall]>();
164165
const startEvent: any = { type: 'tool.execution_start', data: { toolName: 'bash', toolCallId: 'bash-1', arguments: { command: 'echo hi' } } };
165166
const part = processToolExecutionStart(startEvent, pending);
166167
expect(part).toBeInstanceOf(ChatToolInvocationPart);
167168
const completeEvent: any = { type: 'tool.execution_complete', data: { toolName: 'bash', toolCallId: 'bash-1', success: true } };
168-
const completed = processToolExecutionComplete(completeEvent, pending) as ChatToolInvocationPart;
169+
const [completed,] = processToolExecutionComplete(completeEvent, pending)! as [ChatToolInvocationPart, ToolCall];
169170
expect(completed.isComplete).toBe(true);
170171
expect(completed.isError).toBe(false);
171172
expect(completed.isConfirmed).toBe(true);
172173
});
173174
it('marks tool invocation error and unconfirmed when denied', () => {
174-
const pending = new Map<string, ChatToolInvocationPart | ChatResponseThinkingProgressPart>();
175+
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall]>();
175176
processToolExecutionStart({ type: 'tool.execution_start', data: { toolName: 'bash', toolCallId: 'bash-2', arguments: { command: 'rm *' } } } as any, pending);
176177
const completeEvent: any = { type: 'tool.execution_complete', data: { toolName: 'bash', toolCallId: 'bash-2', success: false, error: { message: 'Denied', code: 'denied' } } };
177-
const completed = processToolExecutionComplete(completeEvent, pending) as ChatToolInvocationPart;
178+
const [completed,] = processToolExecutionComplete(completeEvent, pending)! as [ChatToolInvocationPart, ToolCall];
178179
expect(completed.isComplete).toBe(true);
179180
expect(completed.isError).toBe(true);
180181
expect(completed.isConfirmed).toBe(false);

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { PermissionRequest } from './permissionHelpers';
2121
import { ensureRipgrepShim } from './ripgrepShim';
2222

2323
const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel';
24+
const COPILOT_CLI_REQUEST_MAP_KEY = 'github.copilot.cli.requestMap';
2425
// Store last used Agent per workspace.
2526
const COPILOT_CLI_AGENT_MEMENTO_KEY = 'github.copilot.cli.customAgent';
2627
// Store last used Agent for a Session.
@@ -247,11 +248,15 @@ export interface ICopilotCLISDK {
247248
readonly _serviceBrand: undefined;
248249
getPackage(): Promise<typeof import('@github/copilot/sdk')>;
249250
getAuthInfo(): Promise<NonNullable<SessionOptions['authInfo']>>;
251+
getRequestId(sdkRequestId: string): RequestDetails['details'] | undefined;
252+
setRequestId(sdkRequestId: string, details: { requestId: string; toolIdEditMap: Record<string, string> }): void;
250253
getDefaultWorkingDirectory(): Promise<Uri | undefined>;
251254
}
252255

256+
type RequestDetails = { details: { requestId: string; toolIdEditMap: Record<string, string> }; createdDateTime: number };
253257
export class CopilotCLISDK implements ICopilotCLISDK {
254258
declare _serviceBrand: undefined;
259+
private requestMap: Record<string, RequestDetails> = {};
255260

256261
constructor(
257262
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
@@ -260,7 +265,25 @@ export class CopilotCLISDK implements ICopilotCLISDK {
260265
@IInstantiationService protected readonly instantiationService: IInstantiationService,
261266
@IAuthenticationService private readonly authentService: IAuthenticationService,
262267
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
263-
) { }
268+
) {
269+
this.requestMap = this.extensionContext.workspaceState.get<Record<string, RequestDetails>>(COPILOT_CLI_REQUEST_MAP_KEY, {});
270+
}
271+
272+
getRequestId(sdkRequestId: string): RequestDetails['details'] | undefined {
273+
return this.requestMap[sdkRequestId]?.details;
274+
}
275+
276+
setRequestId(sdkRequestId: string, details: { requestId: string; toolIdEditMap: Record<string, string> }): void {
277+
this.requestMap[sdkRequestId] = { details, createdDateTime: Date.now() };
278+
// Prune entries older than 7 days
279+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
280+
for (const [key, value] of Object.entries(this.requestMap)) {
281+
if (value.createdDateTime < sevenDaysAgo) {
282+
delete this.requestMap[key];
283+
}
284+
}
285+
this.extensionContext.workspaceState.update(COPILOT_CLI_REQUEST_MAP_KEY, this.requestMap);
286+
}
264287

265288
public async getPackage(): Promise<typeof import('@github/copilot/sdk')> {
266289
try {

0 commit comments

Comments
 (0)