diff --git a/package.json b/package.json index aa739ed71d..2a848a5d55 100644 --- a/package.json +++ b/package.json @@ -2207,6 +2207,26 @@ "title": "Export All Prompt Logs as JSON...", "icon": "$(export)" }, + { + "command": "github.copilot.nes.captureExpected.start", + "title": "Record Expected Edit (NES)", + "category": "Copilot" + }, + { + "command": "github.copilot.nes.captureExpected.confirm", + "title": "Confirm and Save Expected Edit Capture", + "category": "Copilot" + }, + { + "command": "github.copilot.nes.captureExpected.abort", + "title": "Cancel Expected Edit Capture", + "category": "Copilot" + }, + { + "command": "github.copilot.nes.captureExpected.submit", + "title": "Submit NES Captures", + "category": "Copilot" + }, { "command": "github.copilot.chat.showAsChatSession", "title": "Show as chat session", @@ -4247,6 +4267,14 @@ { "command": "github.copilot.chat.showAsChatSession", "when": "false" + }, + { + "command": "github.copilot.nes.captureExpected.start", + "when": "github.copilot.inlineEditsEnabled" + }, + { + "command": "github.copilot.nes.captureExpected.submit", + "when": "github.copilot.inlineEditsEnabled" } ], "view/title": [ @@ -4722,6 +4750,22 @@ "key": "ctrl+alt+.", "mac": "cmd+alt+.", "when": "github.copilot-chat.activated && terminalShellIntegrationEnabled && terminalFocus && !terminalAltBufferActive" + }, + { + "command": "github.copilot.nes.captureExpected.start", + "key": "ctrl+k ctrl+r", + "mac": "cmd+k cmd+r", + "when": "editorTextFocus && github.copilot.inlineEditsEnabled" + }, + { + "command": "github.copilot.nes.captureExpected.confirm", + "key": "enter", + "when": "copilotNesCaptureMode && editorTextFocus" + }, + { + "command": "github.copilot.nes.captureExpected.abort", + "key": "escape", + "when": "copilotNesCaptureMode && editorTextFocus" } ], "walkthroughs": [ diff --git a/src/extension/inlineEdits/NES_EXPECTED_EDIT_CAPTURE.md b/src/extension/inlineEdits/NES_EXPECTED_EDIT_CAPTURE.md new file mode 100644 index 0000000000..9f69e9886d --- /dev/null +++ b/src/extension/inlineEdits/NES_EXPECTED_EDIT_CAPTURE.md @@ -0,0 +1,249 @@ +# NES Expected Edit Capture Feature + +## Overview + +A feature that allows users to record/capture their "expected suggestion" when a Next Edit Suggestion (NES) was rejected or failed to appear. The captured data is saved in `.recording.w.json` format (compatible with stest infrastructure) for analysis and model improvement. + +## Getting Started + +### 1. Enable the Feature +Add this setting to your VS Code `settings.json`: + +```json +{ + // Enable the capture feature + "github.copilot.chat.advanced.inlineEdits.recordExpectedEdit.enabled": true +} +``` + +That's it! Auto-capture on rejection is enabled by default. To disable it (you can still capture manually via **Cmd+K Cmd+R**): +```json +{ + "github.copilot.chat.advanced.inlineEdits.recordExpectedEdit.onReject": false +} +``` + +### 2. Capture an Expected Edit + +**When NES shows a wrong suggestion:** +1. Reject the suggestion (press `Esc` or continue typing) +2. If `onReject` is enabled, capture mode starts automatically +3. Type the code you *expected* NES to suggest +4. Press **Enter** to save, or **Esc** to cancel + +**When NES didn't appear but should have:** +1. Press **Cmd+K Cmd+R** (Mac) or **Ctrl+K Ctrl+R** (Windows/Linux) +2. Type the code you expected NES to suggest +3. Press **Enter** to save + +> **Tip:** Use **Shift+Enter** to insert newlines during capture (since Enter saves). + +### 3. Submit Your Feedback +Once you've captured some edits: +1. Open Command Palette (**Cmd+Shift+P** / **Ctrl+Shift+P**) +2. Run **"Copilot: Submit NES Captures"** +3. Review the files to be included (you can exclude sensitive files) +4. Click **Submit Feedback** to create a PR + +### Quick Reference + +| Action | Keybinding | +|--------|------------| +| Start capture manually | **Cmd+K Cmd+R** / **Ctrl+K Ctrl+R** | +| Save capture | **Enter** | +| Cancel capture | **Esc** | +| Insert newline | **Shift+Enter** | + +| Command | Description | +|---------|-------------| +| Copilot: Record Expected Edit (NES) | Start a capture session | +| Copilot: Submit NES Captures | Upload feedback to internal repo | + +## How It Works + +### Trigger Points +- **Automatic**: Capture starts when you reject an NES suggestion (if `onReject` setting is enabled) +- **Manual**: Use the keyboard shortcut or Command Palette when NES didn't appear but should have + +### Capture Session +When capture mode is active: +1. A status bar indicator shows: **"NES CAPTURE MODE ACTIVE"** +2. Type your expected edit naturally in the editor +3. Press **Enter** to save or **Esc** to cancel + +### Where Captures Are Saved +Recordings are stored in your workspace under `.copilot/nes-feedback/`: +- `capture-.recording.w.json` — The edit recording +- `capture-.metadata.json` — Context about the capture + +--- + +## Technical Reference + +### Commands + +| Command ID | Description | +|------------|-------------| +| `github.copilot.nes.captureExpected.start` | Start capture manually | +| `github.copilot.nes.captureExpected.confirm` | Confirm and save | +| `github.copilot.nes.captureExpected.abort` | Cancel capture | +| `github.copilot.nes.captureExpected.submit` | Submit to `microsoft/copilot-nes-feedback` | + +### Architecture + +#### State Management +The capture controller maintains minimal state: +```typescript +{ + active: boolean; + startBookmark: DebugRecorderBookmark; + endBookmark?: DebugRecorderBookmark; + startDocumentId: DocumentId; + startTime: number; + trigger: 'rejection' | 'manual'; + originalNesMetadata?: { + requestUuid: string; + providerInfo?: string; + modelName?: string; + endpointUrl?: string; + suggestionText?: string; + // [startLine, startCharacter, endLine, endCharacter] + suggestionRange?: [number, number, number, number]; + documentPath?: string; + }; +} +``` + +### Implementation Flow + +The capture flow leverages **DebugRecorder**, which already tracks all document edits automatically—no custom event listeners or manual diff computation needed. + +1. **Start Capture**: Create a bookmark in DebugRecorder, store the current document ID, set context key `copilotNesCaptureMode` to enable keybindings, and show status bar indicator. + +2. **User Edits**: User types their expected edit naturally in the editor. DebugRecorder automatically tracks all changes in the background. + +3. **Confirm Capture**: Create an end bookmark, extract the log slice between start/end bookmarks, filter for edits on the target document, compose them into a single `nextUserEdit`, and save to disk. + +4. **Abort/Cleanup**: Clear state, reset context key, and dispose status bar item. + +See `ExpectedEditCaptureController` in [vscode-node/components/expectedEditCaptureController.ts](vscode-node/components/expectedEditCaptureController.ts) for the full implementation. + +### File Output + +#### Location +Recordings are stored in the **first workspace folder** under the `.copilot/nes-feedback/` directory: + +- **Full path**: `/.copilot/nes-feedback/capture-.recording.w.json` +- **Timestamp format**: ISO 8601 with colons/periods replaced by hyphens (e.g., `2025-12-04T14-30-45`) +- **Example**: `.copilot/nes-feedback/capture-2025-12-04T14-30-45.recording.w.json` +- The folder is automatically created if it doesn't exist + +Each recording generates two files: +1. **Recording file**: `capture-.recording.w.json` - Contains the log and edit data +2. **Metadata file**: `capture-.metadata.json` - Contains capture context and timing + +#### Format +Matches existing `.recording.w.json` structure used by stest infrastructure: + +```json +{ + "log": [ + { + "kind": "header", + "repoRootUri": "file:///workspace", + "time": 1234567890, + "uuid": "..." + }, + { + "kind": "documentEncountered", + "id": 0, + "relativePath": "src/foo.ts", + "time": 1234567890 + }, + { + "kind": "setContent", + "id": 0, + "v": 1, + "content": "...", + "time": 1234567890 + }, + ... + ], + "nextUserEdit": { + "relativePath": "src/foo.ts", + "edit": [ + [876, 996, "replaced text"], + [1522, 1530, "more text"] + ] + } +} +``` + +#### Metadata File +A metadata file is saved alongside each recording with capture context: +```jsonc +{ + "captureTimestamp": "2025-11-19T...", // ISO timestamp when capture started + "trigger": "rejection", // How capture was initiated: 'rejection' or 'manual' + "durationMs": 5432, // Time between start and confirm in milliseconds + "noEditExpected": false, // True if user confirmed without making edits + "originalNesContext": { // Metadata from the rejected NES suggestion (if any) + "requestUuid": "...", // Unique ID of the NES request + "providerInfo": "...", // Source of the suggestion (e.g., 'provider', 'diagnostics') + "modelName": "...", // AI model that generated the suggestion + "endpointUrl": "...", // API endpoint used for the request + "suggestionText": "...", // The actual suggested text that was rejected + "suggestionRange": [10, 0, 15, 20] // [startLine, startChar, endLine, endChar] of suggestion + } +} +``` + +## Benefits + +- **Zero-friction**: Type naturally, press Enter — no forms or dialogs +- **Works for both**: Rejected suggestions and missed opportunities +- **Privacy-aware**: Sensitive files are automatically filtered before submission + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| **Multiple rapid rejections** | Only one capture active at a time; subsequent rejections ignored | +| **Document closed** | Capture automatically aborted | +| **No edits made** | Valid feedback! Saved with `noEditExpected: true` (indicates the rejection was correct) | +| **Large edits** | DebugRecorder handles size limits automatically | + +## Feedback Submission + +When you run **"Copilot: Submit NES Captures"**: + +1. All captures from `.copilot/nes-feedback/` are collected +2. A preview dialog shows which files will be included +3. You can exclude specific files if needed +4. A pull request is created in `microsoft/copilot-nes-feedback` + +### Privacy & Filtering +Sensitive files are **automatically excluded** from submissions: +- VS Code settings (`settings.json`, `launch.json`) +- Credentials (`.npmrc`, `.env`, `.gitconfig`, etc.) +- Private keys (`.pem`, `.key`, `id_rsa`, etc.) +- Sensitive directories (`.aws/`, `.ssh/`, `.gnupg/`) + +**Requirements:** GitHub authentication with repo access to `microsoft/copilot-nes-feedback` + +--- + +## Future Enhancements + +- **Diff Preview**: Show visual comparison before saving +- **Category Tagging**: Quick-pick to categorize expectation type (import, refactor, etc.) +- **Auto-Generate stest**: Create `.stest.ts` wrapper file automatically + +## Related Files + +- [node/debugRecorder.ts](node/debugRecorder.ts) - Core recording infrastructure +- [vscode-node/components/inlineEditDebugComponent.ts](vscode-node/components/inlineEditDebugComponent.ts) - Existing feedback/debug tooling and sensitive file filtering +- [vscode-node/components/expectedEditCaptureController.ts](vscode-node/components/expectedEditCaptureController.ts) - Capture session management +- [vscode-node/components/nesFeedbackSubmitter.ts](vscode-node/components/nesFeedbackSubmitter.ts) - Feedback submission to GitHub +- [common/observableWorkspaceRecordingReplayer.ts](common/observableWorkspaceRecordingReplayer.ts) - Recording replay logic +- [../../../test/simulation/inlineEdit/inlineEditTester.ts](../../../test/simulation/inlineEdit/inlineEditTester.ts) - stest infrastructure diff --git a/src/extension/inlineEdits/test/node/debugRecorder.spec.ts b/src/extension/inlineEdits/test/node/debugRecorder.spec.ts index 8152bd79c0..7bba12d679 100644 --- a/src/extension/inlineEdits/test/node/debugRecorder.spec.ts +++ b/src/extension/inlineEdits/test/node/debugRecorder.spec.ts @@ -212,3 +212,4 @@ suite('Debug recorder', () => { }); }); + diff --git a/src/extension/inlineEdits/vscode-node/components/expectedEditCaptureController.ts b/src/extension/inlineEdits/vscode-node/components/expectedEditCaptureController.ts new file mode 100644 index 0000000000..8dd61f5047 --- /dev/null +++ b/src/extension/inlineEdits/vscode-node/components/expectedEditCaptureController.ts @@ -0,0 +1,489 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { commands, MarkdownString, StatusBarAlignment, StatusBarItem, ThemeColor, Uri, window, workspace } from 'vscode'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId'; +import { DebugRecorderBookmark } from '../../../../platform/inlineEdits/common/debugRecorderBookmark'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; +import { ISerializedEdit, LogEntry } from '../../../../platform/workspaceRecorder/common/workspaceLog'; +import { Disposable } from '../../../../util/vs/base/common/lifecycle'; +import { DebugRecorder } from '../../node/debugRecorder'; +import { filterLogForSensitiveFiles } from './inlineEditDebugComponent'; +import { NesFeedbackSubmitter } from './nesFeedbackSubmitter'; + +export const copilotNesCaptureMode = 'copilotNesCaptureMode'; + +interface CaptureState { + active: boolean; + startBookmark: DebugRecorderBookmark; + endBookmark?: DebugRecorderBookmark; + startDocumentId: DocumentId; + startTime: number; + trigger: 'rejection' | 'manual'; + originalNesMetadata?: { + requestUuid: string; + providerInfo?: string; + modelName?: string; + endpointUrl?: string; + suggestionText?: string; + suggestionRange?: [number, number, number, number]; + documentPath?: string; + }; +} + +/** + * Controller for capturing expected edit suggestions from users when NES suggestions + * are rejected or don't appear. Leverages DebugRecorder's automatic edit tracking. + */ +export class ExpectedEditCaptureController extends Disposable { + + private static readonly CAPTURE_FOLDER = '.copilot/nes-feedback'; + + private _state: CaptureState | undefined; + private _statusBarItem: StatusBarItem | undefined; + private _statusBarAnimationInterval: ReturnType | undefined; + private readonly _feedbackSubmitter: NesFeedbackSubmitter; + + constructor( + private readonly _debugRecorder: DebugRecorder, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IFetcherService private readonly _fetcherService: IFetcherService, + ) { + super(); + this._feedbackSubmitter = new NesFeedbackSubmitter( + this._logService, + this._authenticationService, + this._fetcherService + ); + } + + /** + * Check if the feature is enabled in settings. + */ + public get isEnabled(): boolean { + return this._configurationService.getConfig(ConfigKey.TeamInternal.RecordExpectedEditEnabled) ?? false; + } + + /** + * Check if automatic capture on rejection is enabled. + */ + public get captureOnReject(): boolean { + return this._configurationService.getConfig(ConfigKey.TeamInternal.RecordExpectedEditOnReject) ?? true; + } + + /** + * Check if a capture session is currently active. + */ + public get isCaptureActive(): boolean { + return this._state?.active ?? false; + } + + /** + * Start a capture session. + * @param trigger How the capture was initiated + * @param nesMetadata Optional metadata about the rejected NES suggestion + */ + public async startCapture( + trigger: 'rejection' | 'manual', + nesMetadata?: CaptureState['originalNesMetadata'] + ): Promise { + if (!this.isEnabled) { + this._logService.trace('[NES Capture] Feature disabled, ignoring start request'); + return; + } + + if (this._state?.active) { + this._logService.trace('[NES Capture] Capture already active, ignoring start request'); + return; + } + + const editor = window.activeTextEditor; + if (!editor) { + this._logService.trace('[NES Capture] No active editor, cannot start capture'); + return; + } + + // Create bookmark to mark the start point + const startBookmark = this._debugRecorder.createBookmark(); + const documentId = DocumentId.create(editor.document.uri.toString()); + + this._state = { + active: true, + startBookmark, + startDocumentId: documentId, + startTime: Date.now(), + trigger, + originalNesMetadata: nesMetadata + }; + + // Set context key to enable keybindings + await commands.executeCommand('setContext', copilotNesCaptureMode, true); + + // Show status bar message + this._createStatusBarItem(); + + this._logService.info(`[NES Capture] Started capture session: trigger=${trigger}, documentUri=${editor.document.uri.toString()}, hasMetadata=${!!nesMetadata}`); + } + + /** + * Confirm and save the capture. + */ + public async confirmCapture(): Promise { + if (!this._state?.active) { + this._logService.trace('[NES Capture] No active capture to confirm'); + return; + } + + try { + // Create end bookmark + const endBookmark = this._debugRecorder.createBookmark(); + this._state.endBookmark = endBookmark; + + // Get log slices + const logUpToStart = this._debugRecorder.getRecentLog(this._state.startBookmark); + const logUpToEnd = this._debugRecorder.getRecentLog(endBookmark); + + if (!logUpToStart || !logUpToEnd) { + this._logService.warn('[NES Capture] Failed to retrieve logs from debug recorder'); + await this.abortCapture(); + return; + } + + // Extract edits between bookmarks + const nextUserEdit = this._extractEditsBetweenBookmarks( + logUpToStart, + logUpToEnd, + this._state.startDocumentId + ); + + // Build recording + // Filter out both non-interacted documents and sensitive files (settings.json, .env) + const filteredLog = filterLogForSensitiveFiles(this._filterLogForNonInteractedDocuments(logUpToStart)); + const recording = { + log: filteredLog, + nextUserEdit: nextUserEdit + }; + + // Save to disk + const noEditExpected = nextUserEdit?.edit && typeof nextUserEdit.edit === 'object' && '__marker__' in nextUserEdit.edit && nextUserEdit.edit.__marker__ === 'NO_EDIT_EXPECTED'; + await this._saveRecording(recording, this._state, noEditExpected); + + const durationMs = Date.now() - this._state.startTime; + this._logService.info(`[NES Capture] Capture confirmed and saved: durationMs=${durationMs}, hasEdit=${!noEditExpected}, noEditExpected=${noEditExpected}, trigger=${this._state.trigger}`); + + if (noEditExpected) { + window.showInformationMessage('Captured: No edit expected (this is valid feedback!).'); + } else { + window.showInformationMessage('Expected edit captured successfully!'); + } + } catch (error) { + this._logService.error('[NES Capture] Error confirming capture', error); + window.showErrorMessage('Failed to save expected edit capture'); + } finally { + await this.cleanup(); + } + } + + /** + * Abort the current capture session without saving. + */ + public async abortCapture(): Promise { + if (!this._state?.active) { + return; + } + + this._logService.info('[NES Capture] Capture aborted'); + await this.cleanup(); + } + + /** + * Clean up capture state and UI. + */ + private async cleanup(): Promise { + this._state = undefined; + await commands.executeCommand('setContext', copilotNesCaptureMode, false); + this._disposeStatusBarItem(); + } + + /** + * Create and show the status bar item during capture with animated attention-grabbing effects. + */ + private _createStatusBarItem(): void { + if (this._statusBarItem) { + this._statusBarItem.dispose(); + } + if (this._statusBarAnimationInterval) { + clearInterval(this._statusBarAnimationInterval); + } + + this._statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 10000); // High priority for visibility + this._statusBarItem.backgroundColor = new ThemeColor('statusBarItem.errorBackground'); + + // Rich markdown tooltip + const tooltip = new MarkdownString(); + tooltip.appendMarkdown('### 🔴 NES CAPTURE MODE ACTIVE\n\n'); + tooltip.appendMarkdown('Type your expected edit, then:\n\n'); + tooltip.appendMarkdown('- ⏎ **Enter** — Save your edits\n'); + tooltip.appendMarkdown('- ⏎ **Enter (empty)** — No edit expected\n'); + tooltip.appendMarkdown('- ⇧⏎ **Shift+Enter** — Insert newline\n'); + tooltip.appendMarkdown('- **Esc** — Cancel capture\n'); + tooltip.isTrusted = true; + this._statusBarItem.tooltip = tooltip; + + // Animated icons and text for attention + const icons = ['$(record)', '$(alert)', '$(warning)', '$(zap)']; + let iconIndex = 0; + let isExpanded = false; + + const updateText = () => { + if (!this._statusBarItem) { + return; + } + const icon = icons[iconIndex]; + if (isExpanded) { + this._statusBarItem.text = `${icon} NES CAPTURE MODE: Enter=save, Esc=cancel ${icon}`; + } else { + this._statusBarItem.text = `${icon} NES CAPTURE MODE ACTIVE ${icon}`; + } + iconIndex = (iconIndex + 1) % icons.length; + isExpanded = !isExpanded; + }; + + updateText(); // Initial text + this._statusBarAnimationInterval = setInterval(updateText, 1000); + + this._statusBarItem.show(); + } + + /** + * Dispose the status bar item and stop animation. + */ + private _disposeStatusBarItem(): void { + if (this._statusBarAnimationInterval) { + clearInterval(this._statusBarAnimationInterval); + this._statusBarAnimationInterval = undefined; + } + if (this._statusBarItem) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } + + /** + * Extract edits that occurred between two bookmarks for a specific document. + * Returns a special marker object with __marker__ field if no edits were made. + */ + private _extractEditsBetweenBookmarks( + logBefore: LogEntry[], + logAfter: LogEntry[], + targetDocId: DocumentId + ): { relativePath: string; edit: ISerializedEdit | { __marker__: 'NO_EDIT_EXPECTED' } } | undefined { + // Find the numeric ID for our target document + let docNumericId: number | undefined; + let relativePath: string | undefined; + + for (const entry of logBefore) { + if (entry.kind === 'documentEncountered') { + const entryPath = entry.relativePath; + // Check if this is our document by comparing paths + if (entryPath && this._pathMatchesDocument(entryPath, targetDocId)) { + docNumericId = entry.id; + relativePath = entry.relativePath; + break; + } + } + } + + if (docNumericId === undefined || !relativePath) { + this._logService.trace('[NES Capture] Could not find document in log'); + return undefined; + } + + // Get only the new entries (diff between logs) + const newEntries = logAfter.slice(logBefore.length); + + // Filter for 'changed' entries on target document + const editEntries = newEntries.filter(e => + e.kind === 'changed' && e.id === docNumericId + ); + + if (editEntries.length === 0) { + this._logService.trace('[NES Capture] No edits found between bookmarks - marking as NO_EDIT_EXPECTED'); + return { + relativePath: relativePath || '', + edit: { __marker__: 'NO_EDIT_EXPECTED' as const } + }; + } + + // Compose all edits into one + let composedEdit: ISerializedEdit = []; + for (const entry of editEntries) { + if (entry.kind === 'changed') { + composedEdit = this._composeSerializedEdits(composedEdit, entry.edit); + } + } + + return { + relativePath, + edit: composedEdit + }; + } + + /** + * Check if a relative path from the log matches a DocumentId. + */ + private _pathMatchesDocument(logPath: string, documentId: DocumentId): boolean { + // Simple comparison - both should be relative paths + // For notebook cells, the log path includes the fragment (e.g., "file.ipynb#cell0") + const docPath = documentId.path; + return logPath.endsWith(docPath) || docPath.endsWith(logPath); + } + + /** + * Compose two serialized edits (similar to StringEdit.compose). + */ + private _composeSerializedEdits( + first: ISerializedEdit, + second: ISerializedEdit + ): ISerializedEdit { + // For now, just concatenate the edits + // TODO: Implement proper composition if needed + return [...first, ...second]; + } + + /** + * Filter out documents that had no user interaction (background/virtual documents). + * Real documents will have user selection, visibility, or edit events. + * This removes startup noise like package.json files from node_modules that VS Code + * opens in the background, while preserving real workspace files that existed before capture. + */ + private _filterLogForNonInteractedDocuments(log: LogEntry[]): LogEntry[] { + // Collect document IDs that had actual user interaction + const interactedDocIds = new Set(); + + for (const entry of log) { + // Documents with these events are "real" documents that the user interacted with + if (entry.kind === 'selectionChanged' || + entry.kind === 'changed') { + if ('id' in entry && typeof entry.id === 'number') { + interactedDocIds.add(entry.id); + } + } + } + + // Collect document IDs that should be excluded (no interaction) + const excludedDocIds = new Set(); + for (const entry of log) { + if (entry.kind === 'documentEncountered') { + if (!interactedDocIds.has(entry.id)) { + excludedDocIds.add(entry.id); + this._logService.trace(`[NES Capture] Filtering out background document: ${entry.relativePath}`); + } + } + } + + // Filter the log to exclude non-interactive documents + return log.filter(entry => { + if (entry.kind === 'header') { + return true; + } + if ('id' in entry && typeof entry.id === 'number') { + return !excludedDocIds.has(entry.id); + } + return true; + }); + } + + /** + * Save the recording to disk in .recording.w.json format. + */ + private async _saveRecording( + recording: { log: LogEntry[]; nextUserEdit?: { relativePath: string; edit: ISerializedEdit | { __marker__: 'NO_EDIT_EXPECTED' } } }, + state: CaptureState, + noEditExpected: boolean = false + ): Promise { + const workspaceFolder = workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder found'); + } + + // Create folder if it doesn't exist + const folderUri = Uri.joinPath(workspaceFolder.uri, ExpectedEditCaptureController.CAPTURE_FOLDER); + try { + await workspace.fs.createDirectory(folderUri); + } catch (error) { + // Ignore if already exists + } + + // Generate filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const filename = `capture-${timestamp}.recording.w.json`; + const fileUri = Uri.joinPath(folderUri, filename); + + // Write file + const content = JSON.stringify(recording, null, 2); + await workspace.fs.writeFile(fileUri, Buffer.from(content, 'utf8')); + + // Optionally save metadata + await this._saveMetadata(folderUri, filename, state, noEditExpected); + + this._logService.info(`[NES Capture] Saved recording: path=${fileUri.fsPath}, noEditExpected=${noEditExpected}`); + } + + /** + * Save additional metadata alongside the recording. + */ + private async _saveMetadata( + folderUri: Uri, + recordingFilename: string, + state: CaptureState, + noEditExpected: boolean = false + ): Promise { + const metadataFilename = recordingFilename.replace('.recording.w.json', '.metadata.json'); + const metadataUri = Uri.joinPath(folderUri, metadataFilename); + + const metadata = { + captureTimestamp: new Date(state.startTime).toISOString(), + trigger: state.trigger, + durationMs: Date.now() - state.startTime, + noEditExpected, + originalNesContext: state.originalNesMetadata + }; + + const content = JSON.stringify(metadata, null, 2); + await workspace.fs.writeFile(metadataUri, Buffer.from(content, 'utf8')); + } + + /** + * Submit all captured NES feedback files to a private GitHub repository. + * Delegates to NesFeedbackSubmitter for file collection, filtering, and upload. + */ + public async submitCaptures(): Promise { + const workspaceFolder = workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + window.showErrorMessage('No workspace folder found'); + return; + } + + const feedbackFolderUri = Uri.joinPath(workspaceFolder.uri, ExpectedEditCaptureController.CAPTURE_FOLDER); + await this._feedbackSubmitter.submitFromFolder(feedbackFolderUri); + } + + override dispose(): void { + // Ensure complete cleanup if disposed during active capture + if (this._state?.active) { + this._state = undefined; + // Note: Can't await in dispose, but this is best-effort cleanup + void commands.executeCommand('setContext', copilotNesCaptureMode, false); + } + this._disposeStatusBarItem(); + super.dispose(); + } +} diff --git a/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts b/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts index 06736705f6..72898ec9a9 100644 --- a/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts +++ b/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts @@ -11,7 +11,7 @@ import { LogEntry } from '../../../../platform/workspaceRecorder/common/workspac import { assertNever } from '../../../../util/vs/base/common/assert'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import { IObservable, ISettableObservable } from '../../../../util/vs/base/common/observableInternal'; -import { basename } from '../../../../util/vs/base/common/path'; +import { basename, extname } from '../../../../util/vs/base/common/path'; import { openIssueReporter } from '../../../conversation/vscode-node/feedbackReporter'; import { XtabProvider } from '../../../xtab/node/xtabProvider'; import { defaultNextEditProviderId } from '../../node/createNextEditProvider'; @@ -161,6 +161,116 @@ stest({ description: 'MyTest', language: 'typescript' }, collection => tester.ru `.toString(); } +/** + * Sensitive file patterns that should be filtered from logs to prevent + * accidental exposure of secrets, credentials, or private configuration. + */ +const SENSITIVE_FILE_PATTERNS = { + // Exact basename matches (case-insensitive) + exactNames: new Set([ + 'settings.json', // VS Code settings + 'keybindings.json', // VS Code keybindings (may contain custom bindings) + 'launch.json', // Debug configs often contain env vars with secrets + '.npmrc', // npm auth tokens + '.netrc', // Network credentials + '.htpasswd', // HTTP auth passwords + '.gitconfig', // Git config can contain tokens + 'credentials', // Generic credentials file + 'credentials.json', + 'secrets.json', + 'config.json', // Often contains API keys + 'password.txt', // Plain text password files + 'passwords.txt', + 'password.json', + 'passwords.json', + 'token.json', // Token storage files + 'tokens.json', + 'token.txt', + 'tokens.txt', + ]), + + // File extensions that are sensitive (checked with endsWith) + extensions: [ + '.env', // Files ending with .env (e.g., app.env, local.env) + '.pem', // Private keys + '.key', // Private keys + '.p12', // PKCS#12 certificates + '.pfx', // PKCS#12 certificates + ], + + // Prefixes for dotfiles that are sensitive (e.g., .env, .env.local, .env.production) + sensitiveDotfilePrefixes: [ + '.env', // Environment files (.env, .env.local, .env.development, etc.) + ], + + // Path segments that indicate sensitive directories + sensitivePathSegments: [ + '.aws', // AWS credentials + '.ssh', // SSH keys + '.gnupg', // GPG keys + '.docker', // Docker config with registry auth + ], + + // Filename patterns (using includes) + patterns: [ + 'id_rsa', // SSH private keys + 'id_ed25519', // SSH private keys + 'id_ecdsa', // SSH private keys + 'id_dsa', // SSH private keys + '.secret', // Files with .secret in name + '_secret', // Files with _secret in name + ], +}; + +/** + * Check if a file path represents a sensitive file that should be filtered. + */ +function isSensitiveFile(relativePath: string): boolean { + // Normalize path separators for consistent handling across platforms + const normalizedPath = relativePath.replace(/\\/g, '/'); + const pathParts = normalizedPath.split('/'); + + // Use basename/extname on normalized path for robust filename extraction + const fileName = basename(normalizedPath); + const fileNameLower = fileName.toLowerCase(); + const fileExt = extname(normalizedPath).toLowerCase(); + + // Check exact filename matches (case-insensitive) + if (SENSITIVE_FILE_PATTERNS.exactNames.has(fileNameLower)) { + return true; + } + + // Check file extensions (e.g., .pem, .key, .p12, .pfx, files ending in .env like app.env) + for (const ext of SENSITIVE_FILE_PATTERNS.extensions) { + if (fileExt === ext || fileNameLower.endsWith(ext)) { + return true; + } + } + + // Check sensitive dotfile prefixes (e.g., .env, .env.local, .env.production) + for (const prefix of SENSITIVE_FILE_PATTERNS.sensitiveDotfilePrefixes) { + if (fileNameLower === prefix || fileNameLower.startsWith(prefix + '.')) { + return true; + } + } + + // Check sensitive path segments + for (const segment of SENSITIVE_FILE_PATTERNS.sensitivePathSegments) { + if (pathParts.some(part => part === segment)) { + return true; + } + } + + // Check filename patterns + for (const pattern of SENSITIVE_FILE_PATTERNS.patterns) { + if (fileNameLower.includes(pattern)) { + return true; + } + } + + return false; +} + export function filterLogForSensitiveFiles(log: LogEntry[]): LogEntry[] { const sensitiveFileIds = new Set(); @@ -181,9 +291,7 @@ export function filterLogForSensitiveFiles(log: LogEntry[]): LogEntry[] { // if so, add it to the sensitive file ids // otherwise, add it to the safe entries case 'documentEncountered': { - const documentBasename = basename(entry.relativePath); - const isSensitiveFile = ['settings.json'].includes(documentBasename) || documentBasename.endsWith(`.env`); - if (isSensitiveFile) { + if (isSensitiveFile(entry.relativePath)) { sensitiveFileIds.add(entry.id); } else { safeEntries.push(entry); diff --git a/src/extension/inlineEdits/vscode-node/components/nesFeedbackSubmitter.ts b/src/extension/inlineEdits/vscode-node/components/nesFeedbackSubmitter.ts new file mode 100644 index 0000000000..212c0cc5ca --- /dev/null +++ b/src/extension/inlineEdits/vscode-node/components/nesFeedbackSubmitter.ts @@ -0,0 +1,690 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { env, Uri, window, workspace } from 'vscode'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; +import { LogEntry } from '../../../../platform/workspaceRecorder/common/workspaceLog'; + +/** + * Represents a feedback file with its name and content. + */ +export interface FeedbackFile { + name: string; + content: string; +} + +/** + * Configuration for the feedback repository. + */ +interface FeedbackRepoConfig { + readonly owner: string; + readonly name: string; + readonly apiUrl: string; +} + +/** + * Handles submission of NES feedback captures to a private GitHub repository. + * Responsible for file collection, user confirmation, filtering, and upload. + */ +export class NesFeedbackSubmitter { + + private static readonly DEFAULT_REPO_CONFIG: FeedbackRepoConfig = { + owner: 'microsoft', + name: 'copilot-nes-feedback', + apiUrl: 'https://api.github.com' + }; + + constructor( + private readonly _logService: ILogService, + private readonly _authenticationService: IAuthenticationService, + private readonly _fetcherService: IFetcherService, + private readonly _repoConfig: FeedbackRepoConfig = NesFeedbackSubmitter.DEFAULT_REPO_CONFIG + ) { } + + /** + * Submit feedback files from the given folder to the private GitHub repository. + * Shows a preview dialog allowing users to select which files to include. + */ + public async submitFromFolder(feedbackFolderUri: Uri): Promise { + try { + // Check if feedback folder exists and has files + const files = await this._collectFeedbackFiles(feedbackFolderUri); + if (files.length === 0) { + window.showInformationMessage('No NES feedback captures found to submit. Use "Copilot: Record Expected Edit (NES)" to capture feedback first.'); + return; + } + + // Read file contents + const fileContents = await this._readFeedbackFiles(files, feedbackFolderUri); + if (fileContents.length === 0) { + window.showErrorMessage('Failed to read feedback files.'); + return; + } + + // Extract unique document paths from the recordings to show the user + const documentPaths = this._extractDocumentPathsFromRecordings(fileContents); + + // Extract nextUserEdit paths to calculate accurate recording counts + const nextUserEditPaths = this._extractNextUserEditPaths(fileContents); + + // Show confirmation with file preview and allow filtering + // Returns excluded paths for efficiency (empty in the default case when all files are selected) + const excludedPaths = await this._showFilePreviewAndConfirm(documentPaths, nextUserEditPaths); + if (!excludedPaths) { + return; + } + + // Filter recordings to remove excluded documents + const filteredContents = this._filterRecordingsByExcludedPaths(fileContents, excludedPaths, nextUserEditPaths); + if (filteredContents.length === 0) { + window.showInformationMessage('No files to submit after filtering.'); + return; + } + + // Get GitHub auth token - need permissive session for repo access + const session = await this._authenticationService.getGitHubSession('permissive', { createIfNone: true }); + if (!session) { + window.showErrorMessage('GitHub authentication required with repo access. Please sign in to GitHub.'); + return; + } + + // Upload files to the private repo + const folderUrl = await this._uploadToPrivateRepo(filteredContents, session.accessToken); + + if (folderUrl) { + await this._showSuccessDialog(folderUrl); + this._logService.info(`[NES Feedback] Uploaded feedback to private repo: ${folderUrl}`); + } + } catch (error) { + this._logService.error('[NES Feedback] Error submitting feedback', error); + window.showErrorMessage(`Failed to submit NES feedback: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Show success dialog with options to open the PR in GitHub or copy the link. + */ + private async _showSuccessDialog(prUrl: string): Promise { + const result = await window.showInformationMessage( + 'Feedback submitted! A pull request has been created.', + 'Open Pull Request', + 'Copy Link' + ); + + if (result === 'Open Pull Request') { + await env.openExternal(Uri.parse(prUrl)); + } else if (result === 'Copy Link') { + await env.clipboard.writeText(prUrl); + window.showInformationMessage('Pull request URL copied to clipboard!'); + } + } + + /** + * Collect all feedback files from the capture folder. + */ + private async _collectFeedbackFiles(folderUri: Uri): Promise { + try { + const entries = await workspace.fs.readDirectory(folderUri); + return entries + .filter(([name, type]) => type === 1 && name.endsWith('.json')) // FileType.File = 1 + .map(([name]) => Uri.joinPath(folderUri, name)); + } catch { + return []; + } + } + + /** + * Read contents of feedback files. + */ + private async _readFeedbackFiles(fileUris: Uri[], folderUri: Uri): Promise { + const results: FeedbackFile[] = []; + + for (const fileUri of fileUris) { + try { + const content = await workspace.fs.readFile(fileUri); + const textContent = new TextDecoder().decode(content); + const relativeName = fileUri.path.replace(folderUri.path + '/', ''); + results.push({ + name: relativeName, + content: textContent + }); + } catch (e) { + this._logService.warn(`[NES Feedback] Failed to read file: ${fileUri.fsPath}: ${e}`); + } + } + + return results; + } + + /** + * Extract unique document paths from recording files. + * Parses the log entries to find all documentEncountered events. + */ + private _extractDocumentPathsFromRecordings(files: FeedbackFile[]): string[] { + const paths = new Set(); + + for (const file of files) { + // Only process recording files, not metadata + if (!file.name.endsWith('.recording.w.json')) { + continue; + } + + try { + const recording = JSON.parse(file.content) as { log?: LogEntry[] }; + if (recording.log) { + for (const entry of recording.log) { + if (entry.kind === 'documentEncountered') { + paths.add(entry.relativePath); + } + } + } + } catch { + // Ignore parse errors + } + } + + return Array.from(paths).sort(); + } + + /** + * Extract the nextUserEdit path for each recording. + * Returns a map from recording name to its nextUserEdit relativePath (or undefined if none). + */ + private _extractNextUserEditPaths(files: FeedbackFile[]): Map { + const result = new Map(); + + for (const file of files) { + if (!file.name.endsWith('.recording.w.json')) { + continue; + } + + try { + const recording = JSON.parse(file.content) as { + nextUserEdit?: { relativePath: string }; + }; + result.set(file.name, recording.nextUserEdit?.relativePath); + } catch { + // If parsing fails, assume it has no nextUserEdit + result.set(file.name, undefined); + } + } + + return result; + } + + /** + * Count how many recordings will be included after excluding certain paths. + * A recording is included only if its nextUserEdit path is not excluded. + */ + private _countIncludedRecordings(nextUserEditPaths: Map, excludedPaths: Set): number { + let count = 0; + for (const [, nextUserEditPath] of nextUserEditPaths) { + if (nextUserEditPath !== undefined && !excludedPaths.has(nextUserEditPath)) { + count++; + } + } + return count; + } + + /** + * Create a summary string for a list of file paths. + * Shows up to maxFiles paths inline, with "and N more..." for the rest. + */ + private _createFilesSummary(paths: string[], maxFiles: number = 5): string { + const sortedPaths = [...paths].sort(); + if (sortedPaths.length <= maxFiles) { + return sortedPaths.join(', '); + } + const shownFiles = sortedPaths.slice(0, maxFiles).join(', '); + return `${shownFiles}, and ${sortedPaths.length - maxFiles} more...`; + } + + /** + * Show a preview of files that will be uploaded and ask for confirmation. + * Uses a QuickPick to allow users to select which files to include. + * @returns The excluded file paths (empty array if all selected), or undefined if cancelled. + */ + private async _showFilePreviewAndConfirm( + documentPaths: string[], + nextUserEditPaths: Map + ): Promise { + const totalRecordingCount = this._countIncludedRecordings(nextUserEditPaths, new Set()); + + if (documentPaths.length === 0) { + // No document paths found, just show basic confirmation + const result = await window.showInformationMessage( + `Found ${totalRecordingCount} feedback recording(s). This will upload your NES feedback to the internal feedback repository.\n\n` + + `Only team members with access to the private repo can view this data.`, + { modal: true }, + 'Submit Feedback' + ); + return result === 'Submit Feedback' ? [] : undefined; // Empty array = no exclusions + } + + // Create a summary of files + const filesSummary = this._createFilesSummary(documentPaths); + + const result = await window.showInformationMessage( + `Found ${totalRecordingCount} recording(s) containing ${documentPaths.length} file(s):\n${filesSummary}\n\n` + + `This will upload your NES feedback to the internal feedback repository.`, + { modal: true }, + 'Submit Feedback', + 'Select Files to Include' + ); + + if (result === 'Submit Feedback') { + return []; // No exclusions - all files selected + } + + if (result === 'Select Files to Include') { + return this._showFileSelectionQuickPick(documentPaths, nextUserEditPaths); + } + + return undefined; + } + + /** + * Show a multi-select QuickPick for file selection. + * Loops until user confirms or cancels, allowing them to edit their selection. + * @returns The excluded file paths, or undefined if cancelled. + */ + private async _showFileSelectionQuickPick( + documentPaths: string[], + nextUserEditPaths: Map + ): Promise { + let currentSelection = new Set(documentPaths); // Start with all selected + + while (true) { + const items = documentPaths.map(path => ({ + label: path, + description: '', + picked: currentSelection.has(path) + })); + + const selected = await window.showQuickPick(items, { + title: 'Select files to include in the upload', + placeHolder: 'Deselect files you want to exclude, then press Enter to confirm', + canPickMany: true, + ignoreFocusOut: true + }); + + if (!selected) { + // User cancelled QuickPick + return undefined; + } + + const selectedPaths = new Set(selected.map(item => item.label)); + const excludedPaths = documentPaths.filter(path => !selectedPaths.has(path)); + + if (selectedPaths.size === 0) { + window.showInformationMessage('No files selected. Upload cancelled.'); + return undefined; + } + + // Calculate how many recordings will actually be included + const excludedPathSet = new Set(excludedPaths); + const includedRecordingCount = this._countIncludedRecordings(nextUserEditPaths, excludedPathSet); + + if (includedRecordingCount === 0) { + const tryAgain = await window.showInformationMessage( + 'No recordings would be included with this selection (all nextUserEdit files are excluded).', + { modal: true }, + 'Edit Selection' + ); + if (tryAgain === 'Edit Selection') { + currentSelection = selectedPaths; + continue; + } + return undefined; + } + + // Show final confirmation with accurate recording count and file summary + const selectedPathsArray = Array.from(selectedPaths); + const filesSummary = this._createFilesSummary(selectedPathsArray); + + const confirmMessage = excludedPaths.length > 0 + ? `Submit ${includedRecordingCount} recording(s) with ${selectedPaths.size} file(s)? (${excludedPaths.length} excluded)\n\nIncluded: ${filesSummary}` + : `Submit ${includedRecordingCount} recording(s) containing ${selectedPaths.size} file(s)?\n\n${filesSummary}`; + + const finalResult = await window.showInformationMessage( + confirmMessage, + { modal: true }, + 'Submit Feedback', + 'Edit Selection' + ); + + if (finalResult === 'Submit Feedback') { + return excludedPaths; + } + + if (finalResult === 'Edit Selection') { + // Update current selection and loop back to QuickPick + currentSelection = selectedPaths; + continue; + } + + // User clicked Cancel or dismissed the dialog + return undefined; + } + } + + /** + * Filter recording files to remove excluded document paths. + * Removes documentEncountered entries and all related events for excluded documents. + * Recordings whose nextUserEdit is excluded are skipped entirely, + * along with their associated metadata files. + * Optimized for the common case where excludedPaths is empty (all files selected). + */ + private _filterRecordingsByExcludedPaths( + files: FeedbackFile[], + excludedPaths: string[], + nextUserEditPaths: Map + ): FeedbackFile[] { + // Fast path: no exclusions, return files as-is + if (excludedPaths.length === 0) { + return files; + } + + const excludedPathSet = new Set(excludedPaths); + const filteredRecordings: FeedbackFile[] = []; + const skippedRecordingPrefixes = new Set(); + + // First pass: filter recordings and track which ones to skip + for (const file of files) { + if (!file.name.endsWith('.recording.w.json')) { + continue; + } + + // Use precomputed nextUserEditPaths to quickly skip recordings + const nextUserEditPath = nextUserEditPaths.get(file.name); + if (nextUserEditPath === undefined || excludedPathSet.has(nextUserEditPath)) { + // Skip this recording - no nextUserEdit or it's excluded + const prefix = file.name.replace('.recording.w.json', ''); + skippedRecordingPrefixes.add(prefix); + this._logService.debug(`[NES Feedback] Skipping recording ${file.name}: nextUserEdit excluded or missing`); + continue; + } + + try { + const filteredFile = this._filterSingleRecording(file, excludedPathSet); + filteredRecordings.push(filteredFile); + } catch { + // If parsing fails, include the file as-is + filteredRecordings.push(file); + } + } + + // Second pass: include metadata files only if their recording wasn't skipped + const result: FeedbackFile[] = [...filteredRecordings]; + for (const file of files) { + if (file.name.endsWith('.metadata.json')) { + const prefix = file.name.replace('.metadata.json', ''); + if (!skippedRecordingPrefixes.has(prefix)) { + result.push(file); + } else { + this._logService.debug(`[NES Feedback] Skipping metadata ${file.name}: associated recording was skipped`); + } + } + } + + return result; + } + + /** + * Filter a single recording file based on excluded document paths. + * Assumes the recording will be included (nextUserEdit already checked). + */ + private _filterSingleRecording(file: FeedbackFile, excludedPathSet: Set): FeedbackFile { + const recording = JSON.parse(file.content) as { + log?: LogEntry[]; + nextUserEdit?: { relativePath: string; edit: unknown }; + }; + + if (!recording.log) { + return file; + } + + // Find document IDs that should be excluded + const excludedDocIds = new Set(); + for (const entry of recording.log) { + if (entry.kind === 'documentEncountered' && excludedPathSet.has(entry.relativePath)) { + excludedDocIds.add(entry.id); + } + } + + // Filter log entries to remove excluded documents + const filteredLog = recording.log.filter(entry => { + if (entry.kind === 'header') { + return true; + } + if ('id' in entry && typeof entry.id === 'number') { + return !excludedDocIds.has(entry.id); + } + return true; + }); + + // Create filtered recording (nextUserEdit is preserved - we already checked it's not excluded) + const filteredRecording = { + ...recording, + log: filteredLog + }; + + return { + name: file.name, + content: JSON.stringify(filteredRecording, null, 2) + }; + } + + /** + * Upload feedback files to the private GitHub repository via a pull request. + * Creates a new branch, uploads files to a timestamped folder, and opens a PR. + * @returns The URL to the pull request, or undefined on failure. + */ + private async _uploadToPrivateRepo(files: FeedbackFile[], token: string): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const folderPath = `feedback/${timestamp}`; + + // Get the current user for commit attribution + const user = await this._getCurrentUser(token); + const username = user?.login ?? 'anonymous'; + + // Create a unique branch name for this feedback submission + const branchName = `feedback/${username}/${timestamp}`; + + // Get the SHA of the main branch to create our branch from + const mainBranchSha = await this._getBranchSha(token, 'main'); + if (!mainBranchSha) { + throw new Error('Failed to get main branch SHA'); + } + + // Create the new branch + await this._createBranch(token, branchName, mainBranchSha); + + // Upload each file to the new branch + for (const file of files) { + const filePath = `${folderPath}/${file.name}`; + await this._createFileInRepo(filePath, file.content, token, username, timestamp, branchName); + } + + // Create the pull request + const prUrl = await this._createPullRequest(token, branchName, username, timestamp, files.length); + + return prUrl; + } + + /** + * Get the SHA of a branch. + */ + private async _getBranchSha(token: string, branch: string): Promise { + try { + const response = await this._fetcherService.fetch( + `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/git/ref/heads/${branch}`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': this._fetcherService.getUserAgentLibrary() + } + } + ); + + if (response.ok) { + const data = await response.json() as { object: { sha: string } }; + return data.object.sha; + } + } catch (e) { + this._logService.error(`[NES Feedback] Failed to get branch SHA: ${e}`); + } + return undefined; + } + + /** + * Create a new branch in the repository. + */ + private async _createBranch(token: string, branchName: string, sha: string): Promise { + const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/git/refs`; + + const payload = { + ref: `refs/heads/${branchName}`, + sha: sha + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': this._fetcherService.getUserAgentLibrary() + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + this._logService.error(`[NES Feedback] Failed to create branch ${branchName}: ${response.status} ${response.statusText} - ${errorText}`); + throw new Error(`Failed to create branch: ${response.statusText}`); + } + } + + /** + * Create a pull request from the feedback branch to main. + * @returns The URL to the created pull request. + */ + private async _createPullRequest( + token: string, + branchName: string, + username: string, + timestamp: string, + fileCount: number + ): Promise { + const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/pulls`; + + const payload = { + title: `NES Feedback from ${username} (${timestamp})`, + head: branchName, + base: 'main', + body: `## NES Feedback Submission\n\n` + + `- **Submitted by:** ${username}\n` + + `- **Timestamp:** ${timestamp}\n` + + `- **Files:** ${fileCount} file(s)\n\n` + + `This feedback was automatically submitted via the "Copilot: Submit NES Feedback" command.` + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': this._fetcherService.getUserAgentLibrary() + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + this._logService.error(`[NES Feedback] Failed to create pull request: ${response.status} ${response.statusText} - ${errorText}`); + throw new Error(`Failed to create pull request: ${response.statusText}`); + } + + const prData = await response.json() as { html_url: string }; + return prData.html_url; + } + + /** + * Create a file in the private feedback repository on a specific branch. + * Uses native fetch API since IFetcherService only supports GET/POST, + * but GitHub Contents API requires PUT for file creation. + */ + private async _createFileInRepo( + path: string, + content: string, + token: string, + username: string, + timestamp: string, + branch: string + ): Promise { + const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/contents/${path}`; + + const payload = { + message: `NES feedback from ${username} at ${timestamp}`, + content: Buffer.from(content).toString('base64'), + branch: branch + }; + + // Use native fetch for PUT request (IFetcherService only supports GET/POST) + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': this._fetcherService.getUserAgentLibrary() + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + this._logService.error(`[NES Feedback] Failed to create file ${path}: ${response.status} ${response.statusText} - ${errorText}`); + throw new Error(`Failed to upload file: ${response.statusText}`); + } + } + + /** + * Get the current authenticated GitHub user. + */ + private async _getCurrentUser(token: string): Promise<{ login: string } | undefined> { + try { + const response = await this._fetcherService.fetch( + `${this._repoConfig.apiUrl}/user`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': this._fetcherService.getUserAgentLibrary() + } + } + ); + + if (response.ok) { + return await response.json(); + } + } catch (e) { + this._logService.warn(`[NES Feedback] Failed to get current user: ${e}`); + } + return undefined; + } +} diff --git a/src/extension/inlineEdits/vscode-node/components/test/inlineEditDebugComponent.spec.ts b/src/extension/inlineEdits/vscode-node/components/test/inlineEditDebugComponent.spec.ts index 41f50ecb07..dab0efc313 100644 --- a/src/extension/inlineEdits/vscode-node/components/test/inlineEditDebugComponent.spec.ts +++ b/src/extension/inlineEdits/vscode-node/components/test/inlineEditDebugComponent.spec.ts @@ -73,4 +73,162 @@ suite('filter recording for sensitive files', () => { `); }); + test('should filter out .env files and variants', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: '.env', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: '.env.local', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: '.env.production', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: 'app.env', time: 0 }, + { kind: 'documentEncountered', id: 5, relativePath: 'src/index.ts', time: 0 }, + { kind: 'setContent', id: 1, v: 1, content: 'SECRET=xyz', time: 0 }, + { kind: 'setContent', id: 5, v: 1, content: 'console.log(\'hello\')', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + // Only header and src/index.ts should remain + expect(result.filter(e => e.kind === 'documentEncountered')).toHaveLength(1); + expect(result.find(e => e.kind === 'documentEncountered')).toMatchObject({ + relativePath: 'src/index.ts' + }); + }); + + test('should filter out private key files', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'server.pem', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'private.key', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: 'cert.p12', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: 'cert.pfx', time: 0 }, + { kind: 'documentEncountered', id: 5, relativePath: 'readme.md', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + expect(result.filter(e => e.kind === 'documentEncountered')).toHaveLength(1); + expect(result.find(e => e.kind === 'documentEncountered')).toMatchObject({ + relativePath: 'readme.md' + }); + }); + + test('should filter out files in sensitive directories', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: '.aws/credentials', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: '.ssh/id_rsa', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: '.gnupg/private-keys-v1.d/key.gpg', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: '.docker/config.json', time: 0 }, + { kind: 'documentEncountered', id: 5, relativePath: 'src/aws/client.ts', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + // Only src/aws/client.ts should remain (it's not in .aws directory) + expect(result.filter(e => e.kind === 'documentEncountered')).toHaveLength(1); + expect(result.find(e => e.kind === 'documentEncountered')).toMatchObject({ + relativePath: 'src/aws/client.ts' + }); + }); + + test('should filter out files with sensitive name patterns', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'id_rsa', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'id_ed25519.pub', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: 'app.secret.yaml', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: 'normal_file.ts', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + expect(result.filter(e => e.kind === 'documentEncountered')).toHaveLength(1); + expect(result.find(e => e.kind === 'documentEncountered')).toMatchObject({ + relativePath: 'normal_file.ts' + }); + }); + + test('should filter exact password/token data files but not code files with those words', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + // These should be filtered (exact sensitive data files) + { kind: 'documentEncountered', id: 1, relativePath: 'password.txt', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'passwords.json', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: 'token.json', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: 'tokens.txt', time: 0 }, + // These should NOT be filtered (code files that deal with passwords/tokens) + { kind: 'documentEncountered', id: 5, relativePath: 'passwordValidator.ts', time: 0 }, + { kind: 'documentEncountered', id: 6, relativePath: 'tokenAnalyzer.ts', time: 0 }, + { kind: 'documentEncountered', id: 7, relativePath: 'auth/refreshToken.service.ts', time: 0 }, + { kind: 'documentEncountered', id: 8, relativePath: 'utils/passwordStrength.ts', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + const remainingPaths = result + .filter((e): e is LogEntry & { kind: 'documentEncountered' } => e.kind === 'documentEncountered') + .map(e => e.relativePath); + + // Only code files should remain + expect(remainingPaths).toEqual([ + 'passwordValidator.ts', + 'tokenAnalyzer.ts', + 'auth/refreshToken.service.ts', + 'utils/passwordStrength.ts', + ]); + }); + + test('should filter out other sensitive config files', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: '.vscode/launch.json', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: '.npmrc', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: '.gitconfig', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: 'credentials.json', time: 0 }, + { kind: 'documentEncountered', id: 5, relativePath: 'tsconfig.json', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + // Only tsconfig.json should remain + expect(result.filter(e => e.kind === 'documentEncountered')).toHaveLength(1); + expect(result.find(e => e.kind === 'documentEncountered')).toMatchObject({ + relativePath: 'tsconfig.json' + }); + }); + + test('should handle Windows-style backslash paths', () => { + // Windows paths with backslashes are normalized to forward slashes before processing + // This ensures the function works correctly even when processing logs recorded on Windows + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: '.vscode\\settings.json', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src\\index.ts', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: 'config\\.env.local', time: 0 }, + { kind: 'documentEncountered', id: 4, relativePath: '.aws\\credentials', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + // Only src\index.ts should remain - others are sensitive + expect(result.filter(e => e.kind === 'documentEncountered')).toHaveLength(1); + expect(result.find(e => e.kind === 'documentEncountered')).toMatchObject({ + relativePath: 'src\\index.ts' + }); + }); + + test('should preserve non-document log entries', () => { + const log: LogEntry[] = [ + { documentType: 'workspaceRecording@1.0', kind: 'header', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'meta', data: { key: 'value' } }, + { kind: 'applicationStart', time: 0 }, + { kind: 'event', time: 0, data: {} }, + { kind: 'bookmark', time: 0 }, + ]; + + const result = filterLogForSensitiveFiles(log); + + expect(result).toHaveLength(5); + }); + }); diff --git a/src/extension/inlineEdits/vscode-node/components/test/nesFeedbackSubmitter.spec.ts b/src/extension/inlineEdits/vscode-node/components/test/nesFeedbackSubmitter.spec.ts new file mode 100644 index 0000000000..4b473ef2d6 --- /dev/null +++ b/src/extension/inlineEdits/vscode-node/components/test/nesFeedbackSubmitter.spec.ts @@ -0,0 +1,600 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, test } from 'vitest'; +import { LogEntry } from '../../../../../platform/workspaceRecorder/common/workspaceLog'; +import { FeedbackFile, NesFeedbackSubmitter } from '../nesFeedbackSubmitter'; + +/** + * Creates a minimal test instance of NesFeedbackSubmitter for testing private methods. + * We use a subclass to expose private methods for testing. + */ +class TestableNesFeedbackSubmitter extends NesFeedbackSubmitter { + constructor() { + // Create minimal mock implementations + const mockLogService = { + _serviceBrand: undefined, + trace: () => { }, + debug: () => { }, + info: () => { }, + warn: () => { }, + error: () => { }, + show: () => { } + }; + + const mockAuthService = { + _serviceBrand: undefined, + isMinimalMode: false, + onDidAuthenticationChange: { dispose: () => { } }, + onDidAccessTokenChange: { dispose: () => { } }, + onDidAdoAuthenticationChange: { dispose: () => { } }, + anyGitHubSession: undefined, + permissiveGitHubSession: undefined, + getGitHubSession: async () => undefined, + getCopilotToken: async () => undefined, + copilotToken: undefined, + resetCopilotToken: () => { }, + speculativeDecodingEndpointToken: undefined, + getAdoAccessTokenBase64: async () => undefined + }; + + const mockFetcherService = { + _serviceBrand: undefined, + fetch: async () => new Response(), + getUserAgentLibrary: () => 'test-agent' + }; + + super(mockLogService as any, mockAuthService as any, mockFetcherService as any); + } + + // Expose private methods for testing + public testExtractDocumentPathsFromRecordings(files: FeedbackFile[]): string[] { + return (this as any)._extractDocumentPathsFromRecordings(files); + } + + public testFilterRecordingsByExcludedPaths(files: FeedbackFile[], excludedPaths: string[]): FeedbackFile[] { + // Compute nextUserEditPaths for the test (mimics what submitFromFolder does) + const nextUserEditPaths = new Map(); + for (const file of files) { + if (file.name.endsWith('.recording.w.json')) { + try { + const recording = JSON.parse(file.content) as { nextUserEdit?: { relativePath: string } }; + nextUserEditPaths.set(file.name, recording.nextUserEdit?.relativePath); + } catch { + nextUserEditPaths.set(file.name, undefined); + } + } + } + return (this as any)._filterRecordingsByExcludedPaths(files, excludedPaths, nextUserEditPaths); + } + + public testFilterSingleRecording(file: FeedbackFile, excludedPathSet: Set): FeedbackFile { + return (this as any)._filterSingleRecording(file, excludedPathSet); + } +} + +describe('NesFeedbackSubmitter', () => { + let submitter: TestableNesFeedbackSubmitter; + + beforeEach(() => { + submitter = new TestableNesFeedbackSubmitter(); + }); + + describe('extractDocumentPathsFromRecordings', () => { + test('should extract unique document paths from recording files', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/utils.ts', time: 0 }, + ] satisfies LogEntry[] + }) + }, + { + name: 'capture-2.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test2' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/other.ts', time: 0 }, + ] satisfies LogEntry[] + }) + } + ]; + + const result = submitter.testExtractDocumentPathsFromRecordings(files); + + expect(result).toEqual(['src/index.ts', 'src/other.ts', 'src/utils.ts']); + }); + + test('should skip metadata files', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/index.ts', time: 0 }, + ] satisfies LogEntry[] + }) + }, + { + name: 'capture-1.metadata.json', + content: JSON.stringify({ + captureTimestamp: '2025-01-01T00:00:00Z', + trigger: 'manual' + }) + } + ]; + + const result = submitter.testExtractDocumentPathsFromRecordings(files); + + expect(result).toEqual(['src/index.ts']); + }); + + test('should handle files with invalid JSON gracefully', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: 'invalid json {' + }, + { + name: 'capture-2.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/valid.ts', time: 0 }, + ] satisfies LogEntry[] + }) + } + ]; + + const result = submitter.testExtractDocumentPathsFromRecordings(files); + + expect(result).toEqual(['src/valid.ts']); + }); + + test('should return empty array for files without log entries', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ someOtherData: true }) + } + ]; + + const result = submitter.testExtractDocumentPathsFromRecordings(files); + + expect(result).toEqual([]); + }); + + test('should return sorted paths', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'z-file.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'a-file.ts', time: 0 }, + { kind: 'documentEncountered', id: 3, relativePath: 'm-file.ts', time: 0 }, + ] satisfies LogEntry[] + }) + } + ]; + + const result = submitter.testExtractDocumentPathsFromRecordings(files); + + expect(result).toEqual(['a-file.ts', 'm-file.ts', 'z-file.ts']); + }); + }); + + describe('filterRecordingsByExcludedPaths', () => { + test('should return files unchanged when no paths are excluded', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/also-keep.ts', time: 0 }, + { kind: 'changed', id: 1, edit: [], v: 1, time: 1 }, + { kind: 'changed', id: 2, edit: [], v: 1, time: 2 }, + ] satisfies LogEntry[] + }) + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, []); + + // Should return exact same array reference (fast path) + expect(result).toBe(files); + }); + + test('should filter out excluded documents from recordings', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 }, + { kind: 'changed', id: 1, edit: [], v: 1, time: 1 }, + { kind: 'changed', id: 2, edit: [], v: 1, time: 2 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/keep.ts', edit: [] } + }) + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']); + + const parsed = JSON.parse(result[0].content); + expect(parsed.log).toHaveLength(3); // header + documentEncountered + changed for id 1 + + const documentPaths = parsed.log + .filter((e: LogEntry) => e.kind === 'documentEncountered') + .map((e: any) => e.relativePath); + expect(documentPaths).toEqual(['src/keep.ts']); + }); + + test('should pass through metadata files when their recording has nextUserEdit', () => { + const metadataContent = JSON.stringify({ + captureTimestamp: '2025-01-01T00:00:00Z', + trigger: 'manual', + durationMs: 5000 + }); + + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/file.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/file.ts', edit: [] } + }) + }, + { + name: 'capture-1.metadata.json', + content: metadataContent + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, []); + + expect(result).toHaveLength(2); + expect(result.find(f => f.name === 'capture-1.metadata.json')?.content).toBe(metadataContent); + }); + + test('should skip recording and metadata when nextUserEdit is excluded', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { + relativePath: 'src/exclude.ts', + edit: [] + } + }) + }, + { + name: 'capture-1.metadata.json', + content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' }) + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']); + + // Both recording and metadata should be skipped + expect(result).toHaveLength(0); + }); + + test('should preserve nextUserEdit if its file is not excluded', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { + relativePath: 'src/keep.ts', + edit: [{ offset: 0, oldLength: 0, newText: 'hello' }] + } + }) + }, + { + name: 'capture-1.metadata.json', + content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' }) + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, []); + + expect(result).toHaveLength(2); + const recording = result.find(f => f.name === 'capture-1.recording.w.json'); + const parsed = JSON.parse(recording!.content); + expect(parsed.nextUserEdit).toBeDefined(); + expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts'); + }); + + test('should skip recording without nextUserEdit entirely', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/file.ts', time: 0 }, + ] satisfies LogEntry[] + // No nextUserEdit + }) + }, + { + name: 'capture-1.metadata.json', + content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:00Z' }) + }, + { + name: 'capture-2.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test2' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/other.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/other.ts', edit: [] } + }) + }, + { + name: 'capture-2.metadata.json', + content: JSON.stringify({ captureTimestamp: '2025-01-01T00:00:01Z' }) + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/file.ts']); + + // Only capture-2 should be included (both recording and metadata) + expect(result).toHaveLength(2); + expect(result.map(f => f.name).sort()).toEqual(['capture-2.metadata.json', 'capture-2.recording.w.json']); + }); + + test('should always preserve header entries in included recordings', () => { + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test-uuid' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/exclude.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/keep.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/keep.ts', edit: [] } + }) + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, ['src/exclude.ts']); + + expect(result).toHaveLength(1); + const parsed = JSON.parse(result[0].content); + expect(parsed.log).toHaveLength(2); // header + documentEncountered for keep.ts + expect(parsed.log[0].kind).toBe('header'); + expect(parsed.log[0].uuid).toBe('test-uuid'); + }); + + test('should skip files with invalid JSON (no parseable nextUserEdit)', () => { + const invalidContent = 'not valid json {{{'; + const files: FeedbackFile[] = [ + { + name: 'capture-1.recording.w.json', + content: invalidContent + } + ]; + + const result = submitter.testFilterRecordingsByExcludedPaths(files, ['anything']); + + // Files with invalid JSON are skipped because nextUserEdit cannot be determined + expect(result).toHaveLength(0); + }); + }); + + describe('filterSingleRecording', () => { + test('should filter all event types for excluded documents', () => { + const file: FeedbackFile = { + name: 'test.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 }, + { kind: 'setContent', id: 1, v: 1, content: 'keep content', time: 1 }, + { kind: 'setContent', id: 2, v: 1, content: 'exclude content', time: 2 }, + { kind: 'changed', id: 1, edit: [], v: 1, time: 3 }, + { kind: 'changed', id: 2, edit: [], v: 1, time: 4 }, + { kind: 'selectionChanged', id: 1, selection: [[0, 0]], time: 5 }, + { kind: 'selectionChanged', id: 2, selection: [[0, 0]], time: 6 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/keep.ts', edit: [] } + }) + }; + + const result = submitter.testFilterSingleRecording(file, new Set(['src/exclude.ts'])); + + const parsed = JSON.parse(result.content); + + // Should have: header, documentEncountered(1), setContent(1), changed(1), selectionChanged(1) + expect(parsed.log).toHaveLength(5); + + // Verify no entries for id 2 + const entriesWithId2 = parsed.log.filter((e: any) => e.id === 2); + expect(entriesWithId2).toHaveLength(0); + + // Verify all entries for id 1 are present + const entriesWithId1 = parsed.log.filter((e: any) => e.id === 1); + expect(entriesWithId1).toHaveLength(4); + + // nextUserEdit should be preserved + expect(parsed.nextUserEdit).toBeDefined(); + expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts'); + }); + + test('should return original file if no log property', () => { + const file: FeedbackFile = { + name: 'test.recording.w.json', + content: JSON.stringify({ someOtherProperty: 'value' }) + }; + + const result = submitter.testFilterSingleRecording(file, new Set(['anything'])); + + expect(result).toBe(file); + }); + + test('should preserve entries without id property', () => { + const file: FeedbackFile = { + name: 'test.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'meta', data: { customKey: 'customValue' } }, + { kind: 'bookmark', time: 100 }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/excluded.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/other.ts', edit: [] } + }) + }; + + const result = submitter.testFilterSingleRecording(file, new Set(['src/excluded.ts'])); + + const parsed = JSON.parse(result.content); + + // Should have header, meta, bookmark (but not documentEncountered) + expect(parsed.log).toHaveLength(3); + expect(parsed.log.map((e: any) => e.kind)).toEqual(['header', 'meta', 'bookmark']); + // nextUserEdit is preserved (its path is not excluded) + expect(parsed.nextUserEdit).toBeDefined(); + }); + + test('should preserve nextUserEdit (caller is responsible for checking exclusion)', () => { + // Note: _filterSingleRecording assumes the caller already verified nextUserEdit is not excluded. + // The filtering of recordings with excluded nextUserEdit happens in _filterRecordingsByExcludedPaths. + const file: FeedbackFile = { + name: 'test.recording.w.json', + content: JSON.stringify({ + log: [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'test' }, + { kind: 'documentEncountered', id: 1, relativePath: 'src/keep.ts', time: 0 }, + { kind: 'documentEncountered', id: 2, relativePath: 'src/exclude.ts', time: 0 }, + ] satisfies LogEntry[], + nextUserEdit: { relativePath: 'src/keep.ts', edit: [] } + }) + }; + + const result = submitter.testFilterSingleRecording(file, new Set(['src/exclude.ts'])); + + const parsed = JSON.parse(result.content); + // nextUserEdit is preserved (filtering happens at a higher level) + expect(parsed.nextUserEdit).toBeDefined(); + expect(parsed.nextUserEdit.relativePath).toBe('src/keep.ts'); + // But the excluded document is filtered out + const docPaths = parsed.log + .filter((e: any) => e.kind === 'documentEncountered') + .map((e: any) => e.relativePath); + expect(docPaths).toEqual(['src/keep.ts']); + }); + }); + + describe('performance', () => { + test('should filter large recordings efficiently', () => { + // Generate a large recording with 10,000 log entries across 100 documents + const documentCount = 100; + const entriesPerDocument = 100; // Total: 10,000 entries + const log: LogEntry[] = [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'perf-test' } + ]; + + // Add document encounters and their events + for (let docId = 1; docId <= documentCount; docId++) { + log.push({ kind: 'documentEncountered', id: docId, relativePath: `src/file${docId}.ts`, time: docId }); + + // Add multiple events per document + for (let i = 0; i < entriesPerDocument - 1; i++) { + const time = docId * 1000 + i; + if (i % 3 === 0) { + log.push({ kind: 'changed', id: docId, edit: [[i, i + 1, 'x']], v: i + 1, time }); + } else if (i % 3 === 1) { + log.push({ kind: 'setContent', id: docId, v: i + 1, content: `content ${i}`, time }); + } else { + log.push({ kind: 'selectionChanged', id: docId, selection: [[i, i + 1]], time }); + } + } + } + + const largeFile: FeedbackFile = { + name: 'large-capture.recording.w.json', + // nextUserEdit points to an even file so it won't be excluded + content: JSON.stringify({ log, nextUserEdit: { relativePath: 'src/file2.ts', edit: [] } }) + }; + + // Exclude half the documents (odd-numbered files) + const excludedPaths = Array.from({ length: documentCount / 2 }, (_, i) => `src/file${i * 2 + 1}.ts`); + + // Measure filtering time + const startTime = performance.now(); + const result = submitter.testFilterRecordingsByExcludedPaths([largeFile], excludedPaths); + const endTime = performance.now(); + const durationMs = endTime - startTime; + + // Verify correctness - recording should be included since nextUserEdit is not excluded + expect(result).toHaveLength(1); + const parsed = JSON.parse(result[0].content); + const remainingDocCount = parsed.log.filter((e: any) => e.kind === 'documentEncountered').length; + expect(remainingDocCount).toBe(documentCount / 2); + + // Performance assertion: should complete within 100ms even for large files + // This threshold is conservative to avoid flaky tests on slower CI machines + expect(durationMs).toBeLessThan(100); + }); + + test('should return immediately when no paths are excluded (fast path)', () => { + // Create a moderately sized recording + const log: LogEntry[] = [ + { kind: 'header', documentType: 'workspaceRecording@1.0', repoRootUri: 'file:///repo', time: 0, uuid: 'fast-path-test' } + ]; + + for (let i = 1; i <= 1000; i++) { + log.push({ kind: 'documentEncountered', id: i, relativePath: `src/file${i}.ts`, time: i }); + log.push({ kind: 'changed', id: i, edit: [], v: 1, time: i + 1 }); + } + + const files: FeedbackFile[] = [{ + name: 'capture.recording.w.json', + content: JSON.stringify({ log }) + }]; + + // Measure time with empty exclusions (fast path) + const startTime = performance.now(); + const result = submitter.testFilterRecordingsByExcludedPaths(files, []); + const endTime = performance.now(); + const durationMs = endTime - startTime; + + // Should return the same array reference (no processing) + expect(result).toBe(files); + + // Should be nearly instant (< 5ms, allowing for overhead) + expect(durationMs).toBeLessThan(5); + }); + }); +}); diff --git a/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index 88babdba2e..7e3e9226fd 100644 --- a/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -40,6 +40,7 @@ import { createCorrelationId } from '../common/correlationId'; import { NESInlineCompletionContext } from '../node/nextEditProvider'; import { NextEditProviderTelemetryBuilder, TelemetrySender } from '../node/nextEditProviderTelemetry'; import { INextEditResult, NextEditResult } from '../node/nextEditResult'; +import { ExpectedEditCaptureController } from './components/expectedEditCaptureController'; import { InlineCompletionCommand, InlineEditDebugComponent } from './components/inlineEditDebugComponent'; import { LogContextRecorder } from './components/logContextRecorder'; import { DiagnosticsNextEditResult } from './features/diagnosticsInlineEditProvider'; @@ -135,6 +136,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo private readonly logContextRecorder: LogContextRecorder | undefined, private readonly inlineEditDebugComponent: InlineEditDebugComponent | undefined, private readonly telemetrySender: TelemetrySender, + private readonly expectedEditCaptureController: ExpectedEditCaptureController | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IDiffService private readonly _diffService: IDiffService, @@ -193,6 +195,12 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo ): Promise { const tracer = this._tracer.sub(['provideInlineCompletionItems', shortenOpportunityId(context.requestUuid)]); + // Disable NES while capture mode is active to avoid interference + if (this.expectedEditCaptureController?.isCaptureActive) { + tracer.returns('capture mode active'); + return undefined; + } + const isCompletionsEnabled = this._isCompletionsEnabled(document); const unification = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsUnification, this._expService); @@ -491,6 +499,26 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo } case InlineCompletionEndOfLifeReasonKind.Rejected: { this._handleDidRejectCompletionItem(item); + // Trigger expected edit capture if enabled + if (this.expectedEditCaptureController?.isEnabled && this.expectedEditCaptureController?.captureOnReject) { + // Get endpoint info from the log context if available (LLM suggestions only) + const endpointInfo = isLlmCompletionInfo(item.info) ? item.info.suggestion.source.log.endpointInfo : undefined; + const metadata = { + requestUuid: item.info.requestUuid, + providerInfo: item.info.source, + modelName: endpointInfo?.modelName, + endpointUrl: endpointInfo?.url, + suggestionText: item.insertText?.toString(), + suggestionRange: item.range ? [ + item.range.start.line, + item.range.start.character, + item.range.end.line, + item.range.end.character + ] as [number, number, number, number] : undefined, + documentPath: item.info.documentId.path + }; + void this.expectedEditCaptureController.startCapture('rejection', metadata); + } break; } case InlineCompletionEndOfLifeReasonKind.Ignored: { diff --git a/src/extension/inlineEdits/vscode-node/inlineEditModel.ts b/src/extension/inlineEdits/vscode-node/inlineEditModel.ts index 1746b36640..9355c4970a 100644 --- a/src/extension/inlineEdits/vscode-node/inlineEditModel.ts +++ b/src/extension/inlineEdits/vscode-node/inlineEditModel.ts @@ -30,7 +30,7 @@ const TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN = 5000; // milliseconds const TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN = 5000; // 5s export class InlineEditModel extends Disposable { - public readonly debugRecorder = this._register(new DebugRecorder(this.workspace)); + public readonly debugRecorder: DebugRecorder; public readonly nextEditProvider: NextEditProvider; private readonly _predictor: IStatelessNextEditProvider; @@ -50,6 +50,8 @@ export class InlineEditModel extends Disposable { ) { super(); + this.debugRecorder = this._register(new DebugRecorder(this.workspace)); + this._predictor = createNextEditProvider(this._predictorId, this._instantiationService); const xtabDiffNEntries = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabDiffNEntries, this._expService); const xtabHistoryTracker = new NesXtabHistoryTracker(this.workspace, xtabDiffNEntries); diff --git a/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts b/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts index cc1573e81f..48d0da3e69 100644 --- a/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts +++ b/src/extension/inlineEdits/vscode-node/inlineEditProviderFeature.ts @@ -23,6 +23,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { IExtensionContribution } from '../../common/contributions'; import { unificationStateObservable } from '../../completions/vscode-node/completionsUnificationContribution'; import { TelemetrySender } from '../node/nextEditProviderTelemetry'; +import { ExpectedEditCaptureController } from './components/expectedEditCaptureController'; import { InlineEditDebugComponent, reportFeedbackCommandId } from './components/inlineEditDebugComponent'; import { LogContextRecorder } from './components/logContextRecorder'; import { DiagnosticsNextEditProvider } from './features/diagnosticsInlineEditProvider'; @@ -48,7 +49,7 @@ export class InlineEditProviderFeatureContribution extends Disposable implements const inlineEditProviderFeature = this._instantiationService.createInstance(InlineEditProviderFeature); this._register(inlineEditProviderFeature.rolloutFeature()); this._register(inlineEditProviderFeature.registerProvider()); - inlineEditProviderFeature.setContext(); + this._register(inlineEditProviderFeature.setContext()); tracer.returns(); } @@ -95,10 +96,16 @@ export class InlineEditProviderFeature { ) { } - public setContext(): void { + public setContext(): IDisposable { // TODO: this should be reactive to config changes const enableEnhancedNotebookNES = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.UseAlternativeNESNotebookFormat, this._expService) || this._configurationService.getExperimentBasedConfig(ConfigKey.UseAlternativeNESNotebookFormat, this._expService); commands.executeCommand('setContext', useEnhancedNotebookNESContextKey, enableEnhancedNotebookNES); + + // Set context key for inline edits enabled state (used for keybindings) + return autorun((reader) => { + const enabled = this.inlineEditsEnabled.read(reader); + void commands.executeCommand('setContext', 'github.copilot.inlineEditsEnabled', enabled); + }); } public rolloutFeature(): IDisposable { @@ -164,7 +171,13 @@ export class InlineEditProviderFeature { const telemetrySender = reader.store.add(this._instantiationService.createInstance(TelemetrySender)); - const provider = this._instantiationService.createInstance(InlineCompletionProviderImpl, model, logger, logContextRecorder, inlineEditDebugComponent, telemetrySender); + // Create the expected edit capture controller + const expectedEditCaptureController = reader.store.add(this._instantiationService.createInstance( + ExpectedEditCaptureController, + model.debugRecorder + )); + + const provider = this._instantiationService.createInstance(InlineCompletionProviderImpl, model, logger, logContextRecorder, inlineEditDebugComponent, telemetrySender, expectedEditCaptureController); const unificationStateValue = unificationState.read(reader); let excludes = this._excludedProviders.read(reader); @@ -210,6 +223,23 @@ export class InlineEditProviderFeature { logContext.recordingBookmark = model.debugRecorder.createBookmark(); void commands.executeCommand(reportFeedbackCommandId, { logContext }); })); + + // Register expected edit capture commands + reader.store.add(commands.registerCommand(captureExpectedStartCommandId, () => { + void expectedEditCaptureController.startCapture('manual'); + })); + + reader.store.add(commands.registerCommand(captureExpectedConfirmCommandId, () => { + void expectedEditCaptureController.confirmCapture(); + })); + + reader.store.add(commands.registerCommand(captureExpectedAbortCommandId, () => { + void expectedEditCaptureController.abortCapture(); + })); + + reader.store.add(commands.registerCommand(captureExpectedSubmitCommandId, () => { + void expectedEditCaptureController.submitCaptures(); + })); }); } } @@ -220,3 +250,7 @@ export const learnMoreLink = 'https://aka.ms/vscode-nes'; export const clearCacheCommandId = 'github.copilot.debug.inlineEdit.clearCache'; export const reportNotebookNESIssueCommandId = 'github.copilot.debug.inlineEdit.reportNotebookNESIssue'; +const captureExpectedStartCommandId = 'github.copilot.nes.captureExpected.start'; +const captureExpectedConfirmCommandId = 'github.copilot.nes.captureExpected.confirm'; +const captureExpectedAbortCommandId = 'github.copilot.nes.captureExpected.abort'; +const captureExpectedSubmitCommandId = 'github.copilot.nes.captureExpected.submit'; diff --git a/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts b/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts index 80df2e83fb..5c299a1b33 100644 --- a/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts +++ b/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts @@ -39,6 +39,7 @@ import { CompletionsCoreContribution } from '../../completions/vscode-node/compl import { unificationStateObservable } from '../../completions/vscode-node/completionsUnificationContribution'; import { NESInlineCompletionContext } from '../node/nextEditProvider'; import { TelemetrySender } from '../node/nextEditProviderTelemetry'; +import { ExpectedEditCaptureController } from './components/expectedEditCaptureController'; import { InlineEditDebugComponent, reportFeedbackCommandId } from './components/inlineEditDebugComponent'; import { LogContextRecorder } from './components/logContextRecorder'; import { DiagnosticsNextEditProvider } from './features/diagnosticsInlineEditProvider'; @@ -149,7 +150,13 @@ export class JointCompletionsProviderContribution extends Disposable implements const telemetrySender = reader.store.add(this._instantiationService.createInstance(TelemetrySender)); - inlineEditProvider = this._instantiationService.createInstance(InlineCompletionProviderImpl, model, logger, logContextRecorder, inlineEditDebugComponent, telemetrySender); + // Create the expected edit capture controller + const expectedEditCaptureController = reader.store.add(this._instantiationService.createInstance( + ExpectedEditCaptureController, + model.debugRecorder + )); + + inlineEditProvider = this._instantiationService.createInstance(InlineCompletionProviderImpl, model, logger, logContextRecorder, inlineEditDebugComponent, telemetrySender, expectedEditCaptureController); reader.store.add(vscode.commands.registerCommand(learnMoreCommandId, () => { this._envService.openExternal(URI.parse(learnMoreLink)); diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index 4008495045..4d40c72da6 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -792,6 +792,8 @@ export namespace ConfigKey { export const InstantApplyModelName = defineTeamInternalSetting('chat.advanced.instantApply.modelName', ConfigType.ExperimentBased, CHAT_MODEL.GPT4OPROXY); export const UseProxyModelsServiceForInstantApply = defineTeamInternalSetting('chat.advanced.instantApply.useProxyModelsService', ConfigType.ExperimentBased, false); export const VerifyTextDocumentChanges = defineTeamInternalSetting('chat.advanced.inlineEdits.verifyTextDocumentChanges', ConfigType.ExperimentBased, false); + export const RecordExpectedEditEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.recordExpectedEdit.enabled', ConfigType.Simple, false); + export const RecordExpectedEditOnReject = defineTeamInternalSetting('chat.advanced.inlineEdits.recordExpectedEdit.onReject', ConfigType.Simple, true); // TODO: @sandy081 - These should be moved away from this namespace export const EnableReadFileV2 = defineSetting('chat.advanced.enableReadFileV2', ConfigType.ExperimentBased, isPreRelease); diff --git a/src/platform/inlineEdits/common/inlineEditLogContext.ts b/src/platform/inlineEdits/common/inlineEditLogContext.ts index 7eee79927d..4be47c0a8f 100644 --- a/src/platform/inlineEdits/common/inlineEditLogContext.ts +++ b/src/platform/inlineEdits/common/inlineEditLogContext.ts @@ -274,6 +274,10 @@ export class InlineEditRequestLogContext { this._endpointInfo = { url, modelName }; } + public get endpointInfo(): { url: string; modelName: string } | undefined { + return this._endpointInfo; + } + public _prompt: string | undefined = undefined; get prompt(): string | undefined {