autopilot copilot code review - #5013
Justin Chen (justschen) wants to merge 1 commit into
Conversation
Justin Chen (justschen)
commented
Apr 6, 2026
- automatically starts a code review session if we detect edits
There was a problem hiding this comment.
Pull request overview
Adds an “autopilot” flow that can automatically run Copilot code review after the agent has made edits, so the model can address review comments before final completion.
Changes:
- Extend autopilot
ToolCallingLoopto detect edit-tool usage, snapshot pre-edit file contents, and triggerreviewFileChangeson edited files when the task completes. - Extend code review inputs to accept
baseContent(inline) as an alternative tobaseUri, and plumb this throughreviewFileChanges. - Thread
IPromptPathRepresentationServiceinto tool-calling loops to resolve tool-provided file paths into URIs.
Show a summary per file
| File | Description |
|---|---|
| src/extension/intents/node/toolCallingLoop.ts | Implements autopilot edit detection, pre-edit snapshotting, and automatic code review continuation logic. |
| src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts | Exposes additional protected members for autopilot-related unit testing. |
| src/extension/review/node/doReview.ts | Allows reviewFileChanges to use provided baseContent and only falls back to baseUri reads when needed. |
| src/platform/review/common/reviewCommand.ts | Extends CodeReviewFileInput to allow passing baseContent directly. |
| src/platform/configuration/common/configurationService.ts | Introduces config key to enable/disable autopilot code review. |
| src/extension/prompt/node/defaultIntentRequestHandler.ts | Updates ToolCallingLoop construction to pass IPromptPathRepresentationService. |
| src/extension/prompt/node/codebaseToolCalling.ts | Updates ToolCallingLoop construction to pass IPromptPathRepresentationService. |
| src/extension/prompt/node/searchSubagentToolCallingLoop.ts | Updates ToolCallingLoop construction to pass IPromptPathRepresentationService. |
| src/extension/prompt/node/executionSubagentToolCallingLoop.ts | Updates ToolCallingLoop construction to pass IPromptPathRepresentationService. |
| src/extension/mcp/vscode-node/mcpToolCallingLoop.tsx | Updates ToolCallingLoop construction to pass IPromptPathRepresentationService. |
Copilot's findings
- Files reviewed: 10/10 changed files
- Comments generated: 5
| const headerRe = /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)/g; | ||
| let m: RegExpExecArray | null; | ||
| while ((m = headerRe.exec(patchText)) !== null) { | ||
| paths.add(m[1].trim()); | ||
| } |
There was a problem hiding this comment.
apply_patch supports renames via *** Move to: <new path> headers, but extractEditedFilePathsFromRound only captures the *** (Add|Update|Delete) File: path. In rename cases the edited file list (and pre-edit snapshot lookup) can miss the new path, causing the review to either skip the file or diff it against an empty base. Consider also extracting *** Move to: targets (and/or mapping snapshots from old->new path) so renamed files are reviewed correctly.
| const headerRe = /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)/g; | |
| let m: RegExpExecArray | null; | |
| while ((m = headerRe.exec(patchText)) !== null) { | |
| paths.add(m[1].trim()); | |
| } | |
| const fileHeaderRe = /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)/g; | |
| const moveToHeaderRe = /\*\*\*\s+Move to:\s+(.+)/g; | |
| let m: RegExpExecArray | null; | |
| while ((m = fileHeaderRe.exec(patchText)) !== null) { | |
| paths.add(m[1].trim()); | |
| } | |
| while ((m = moveToHeaderRe.exec(patchText)) !== null) { | |
| paths.add(m[1].trim()); | |
| } |
| // Mark review as completed so we don't re-run after the fix cycle. | ||
| this.autopilotCodeReviewCompleted = true; | ||
|
|
There was a problem hiding this comment.
autopilotCodeReviewCompleted is set to true before the review actually runs. If reviewFileChanges throws or the token is cancelled mid-review, this prevents any subsequent attempt to run the review, effectively skipping it permanently for the session. Consider only setting this flag after a successful review attempt (or resetting it in the catch path) so transient failures don’t disable code review.
| // Mark review as completed so we don't re-run after the fix cycle. | |
| this.autopilotCodeReviewCompleted = true; |
| /** | ||
| * A single file to review, specified by URI pairs. | ||
| */ | ||
| export interface CodeReviewFileInput { | ||
| readonly currentUri: vscode.Uri; | ||
| readonly baseUri?: vscode.Uri; | ||
| readonly baseContent?: string; | ||
| } |
There was a problem hiding this comment.
The file header comment says inputs are “specified by URI pairs”, but the interface now also supports baseContent (which can replace baseUri). Update the doc comment to reflect that callers may provide either baseUri or inline baseContent for the base side of the diff.
| export const WorkspacePrototypeAdoCodeSearchEndpointOverride = defineAndMigrateSetting<string>('chat.advanced.workspace.prototypeAdoCodeSearchEndpointOverride', 'chat.workspace.prototypeAdoCodeSearchEndpointOverride', ''); | ||
| export const FeedbackOnChange = defineAndMigrateSetting('chat.advanced.feedback.onChange', 'chat.feedback.onChange', false); | ||
| export const ReviewIntent = defineAndMigrateSetting('chat.advanced.review.intent', 'chat.review.intent', false); | ||
| export const AutopilotCodeReviewEnabled = defineSetting<boolean>('chat.advanced.autopilotCodeReview.enabled', ConfigType.Simple, false); |
There was a problem hiding this comment.
ConfigKey.Advanced.AutopilotCodeReviewEnabled is defined in code, but there is no corresponding contributed setting in package.json (so it won’t show up in Settings UI and will be treated as non-public by defineSetting). If this is intended to be user-configurable/experiment-togglable, add a github.copilot.chat... configuration entry (and consider aligning the key naming with other chat settings that don’t include a .advanced. segment).
| export const AutopilotCodeReviewEnabled = defineSetting<boolean>('chat.advanced.autopilotCodeReview.enabled', ConfigType.Simple, false); | |
| export const AutopilotCodeReviewEnabled = defineAndMigrateSetting<boolean>('chat.advanced.autopilotCodeReview.enabled', 'chat.autopilotCodeReview.enabled', false); |
| /** | ||
| * Returns `true` if any tool call round contains a code-editing tool invocation. | ||
| */ | ||
| protected hadCodeEdits(): boolean { | ||
| return this.toolCallRounds.some( | ||
| round => round.toolCalls.some(tc => ToolCallingLoop.EDIT_TOOL_NAMES.has(tc.name)) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Extracts deduplicated file paths from a single round's edit tool calls. | ||
| */ | ||
| private static extractEditedFilePathsFromRound(round: IToolCallRound): string[] { | ||
| const paths = new Set<string>(); | ||
| for (const tc of round.toolCalls) { | ||
| if (!ToolCallingLoop.EDIT_TOOL_NAMES.has(tc.name)) { | ||
| continue; | ||
| } | ||
| try { | ||
| const args = JSON.parse(tc.arguments); | ||
| if (tc.name === ToolName.MultiReplaceString) { | ||
| const replacements: { filePath?: string }[] = args.replacements ?? []; | ||
| for (const r of replacements) { | ||
| if (r.filePath) { | ||
| paths.add(r.filePath); | ||
| } | ||
| } | ||
| } else if (tc.name === ToolName.ApplyPatch) { | ||
| const patchText: string = args.input ?? ''; | ||
| const headerRe = /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)/g; | ||
| let m: RegExpExecArray | null; | ||
| while ((m = headerRe.exec(patchText)) !== null) { | ||
| paths.add(m[1].trim()); | ||
| } | ||
| } else if (args.filePath) { | ||
| paths.add(args.filePath); | ||
| } | ||
| } catch { | ||
| // malformed arguments — skip | ||
| } | ||
| } | ||
| return [...paths]; | ||
| } | ||
|
|
||
| /** | ||
| * Scans tool call rounds for code-editing tools and extracts the deduplicated | ||
| * set of file paths that were edited. | ||
| */ | ||
| protected getEditedFilePaths(): URI[] { | ||
| const paths = new Set<string>(); | ||
| for (const round of this.toolCallRounds) { | ||
| for (const p of ToolCallingLoop.extractEditedFilePathsFromRound(round)) { | ||
| paths.add(p); | ||
| } | ||
| } | ||
| return [...paths].flatMap(p => { | ||
| try { | ||
| return [resolveToolInputPath(p, this._promptPathRepresentationService)]; | ||
| } catch { | ||
| return []; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * After a round's tool calls are known but before they execute (next iteration), | ||
| * capture the current file content so we have a pre-edit snapshot for code review. | ||
| * Only captures each file once — the first snapshot represents the state before | ||
| * autopilot's first edit to that file. | ||
| */ | ||
| private async capturePreEditSnapshots(round: IToolCallRound): Promise<void> { | ||
| if (this.options.request.permissionLevel !== 'autopilot') { | ||
| return; | ||
| } | ||
| const filePaths = ToolCallingLoop.extractEditedFilePathsFromRound(round); | ||
| const newEntries: { key: string; uri: URI }[] = []; | ||
| for (const p of filePaths) { | ||
| try { | ||
| const uri = resolveToolInputPath(p, this._promptPathRepresentationService); | ||
| const key = uri.fsPath; | ||
| if (!this.preEditSnapshots.has(key)) { | ||
| newEntries.push({ key, uri }); | ||
| } | ||
| } catch { | ||
| // invalid path — skip | ||
| } | ||
| } | ||
| if (!newEntries.length) { | ||
| return; | ||
| } | ||
| await Promise.all(newEntries.map(async ({ key, uri }) => { | ||
| try { | ||
| const bytes = await this._fileSystemService.readFile(uri); | ||
| if (!this.preEditSnapshots.has(key)) { | ||
| this.preEditSnapshots.set(key, new TextDecoder().decode(bytes)); | ||
| } | ||
| } catch { | ||
| // File doesn't exist yet (e.g. create_file) — record empty string | ||
| if (!this.preEditSnapshots.has(key)) { | ||
| this.preEditSnapshots.set(key, ''); | ||
| } | ||
| } | ||
| })); | ||
| } | ||
|
|
||
| /** | ||
| * Formats code review comments into a continuation message for the model. | ||
| */ | ||
| private formatReviewCommentsForModel(comments: readonly CodeReviewComment[]): string { | ||
| const lines: string[] = [ | ||
| 'Code review found the following issues with your changes. Please address each one:\n', | ||
| ]; | ||
| for (const comment of comments) { | ||
| const file = comment.uri.fsPath; | ||
| const startLine = comment.range.start.line + 1; | ||
| const endLine = comment.range.end.line + 1; | ||
| const loc = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`; | ||
| lines.push(`- **${file}** (${loc}) [${comment.severity}]: ${comment.body}`); | ||
| } | ||
| lines.push( | ||
| '', | ||
| 'After addressing all review comments, call task_complete with a brief summary of what was fixed.', | ||
| ); | ||
| return lines.join('\n'); | ||
| } | ||
|
|
||
| /** | ||
| * Runs code review on files edited during autopilot, if applicable. | ||
| * Returns `true` if review comments were found and the loop should continue | ||
| * so the model can address them. | ||
| */ | ||
| protected async performAutopilotCodeReview( | ||
| outputStream: ChatResponseStream | undefined, | ||
| token: CancellationToken, | ||
| ): Promise<boolean> { | ||
| if (this.options.request.permissionLevel !== 'autopilot') { | ||
| return false; | ||
| } | ||
| if (!this._configurationService.getConfig(ConfigKey.Advanced.AutopilotCodeReviewEnabled)) { |
There was a problem hiding this comment.
Autopilot code review introduces non-trivial new behavior (detecting edit tools, snapshotting pre-edit file content, invoking reviewFileChanges, and continuing the loop with formatted comments), but there are no new assertions in the existing ToolCallingLoop Vitest suites to cover these paths. Add unit tests (e.g. in toolCallingLoopAutopilot.spec.ts) covering: edit detection (hadCodeEdits), file path extraction (including apply_patch parsing), snapshot usage, and the "review found comments -> continue" flow.