diff --git a/scripts/code-sessions-web.js b/scripts/code-sessions-web.js index 217ecf0a4e0..c652b32e79e 100644 --- a/scripts/code-sessions-web.js +++ b/scripts/code-sessions-web.js @@ -147,7 +147,7 @@ ${importMapJson} ${additionalBuiltinExtensions} workspaceProvider: { workspace: ${useMock - ? `{ folderUri: URI.parse('mock-fs://mock-repo/') }` + ? `{ folderUri: URI.parse('mock-fs://mock-repo/mock-repo') }` : 'undefined'}, open: async () => false, payload: [['isSessionsWindow', 'true']], diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 5b0047e74a3..7d60866f371 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -5263,7 +5263,7 @@ declare namespace monaco.editor { export const EditorOptions: { acceptSuggestionOnCommitCharacter: IEditorOption; acceptSuggestionOnEnter: IEditorOption; - accessibilitySupport: IEditorOption; + accessibilitySupport: IEditorOption; accessibilityPageSize: IEditorOption; allowOverflow: IEditorOption; allowVariableLineHeights: IEditorOption; @@ -5326,7 +5326,7 @@ declare namespace monaco.editor { foldingMaximumRegions: IEditorOption; unfoldOnClickAfterEndOfLine: IEditorOption; fontFamily: IEditorOption; - fontInfo: IEditorOption; + fontInfo: IEditorOption; fontLigatures2: IEditorOption; fontSize: IEditorOption; fontWeight: IEditorOption; @@ -5366,7 +5366,7 @@ declare namespace monaco.editor { pasteAs: IEditorOption>>; parameterHints: IEditorOption>>; peekWidgetDefaultFocus: IEditorOption; - placeholder: IEditorOption; + placeholder: IEditorOption; definitionLinkOpensInPeek: IEditorOption; quickSuggestions: IEditorOption; quickSuggestionsDelay: IEditorOption; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 528747fbe17..9a335e542f8 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -157,7 +157,9 @@ import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contri import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; -import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/changes/browser/changesView.contribution.js'; +import './contrib/codeReview/browser/codeReview.contributions.js'; +import './contrib/github/browser/github.contribution.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; diff --git a/src/vs/sessions/test/e2e/README.md b/src/vs/sessions/test/e2e/README.md index a5ee01b52bd..a2b6cce5d3f 100644 --- a/src/vs/sessions/test/e2e/README.md +++ b/src/vs/sessions/test/e2e/README.md @@ -4,6 +4,92 @@ Automated dogfooding tests for the Agent Sessions window using a **compile-and-replay** architecture powered by [`playwright-cli`](https://github.com/microsoft/playwright-cli) and Copilot CLI. +## Mocking Architecture + +These tests run the **real** Sessions workbench with only the minimal set of +services mocked — specifically the services that require external backends +(auth, LLM, git). Everything downstream from the mock agent's canned response +runs through the real code paths. + +### What's Mocked (Minimal) + +| Service | Mock | Why | +|---------|------|-----| +| `IChatEntitlementService` | Returns `ChatEntitlement.Free` | No real Copilot account in CI | +| `IDefaultAccountService` | Returns a fake signed-in account | Hides the "Sign In" button | +| `IGitService` | Resolves immediately (no 10s barrier) | No real git extension in web tests | +| Chat agents (`copilotcli`, etc.) | Canned keyword-matched responses with `textEdit` progress items | No real LLM backend | +| `mock-fs://` FileSystemProvider | `InMemoryFileSystemProvider` registered directly in the workbench (not extension host) | Must be available before any service tries to resolve workspace files | +| GitHub authentication | Always-signed-in mock provider (extension) | No real OAuth flow | + +### What's Real (Everything Else) + +The following services run with their **real** implementations, ensuring tests +exercise the actual code paths: + +- **`ChatEditingService`** — Processes `textEdit` progress items from the mock + agent, creates `IModifiedFileEntry` objects with real before/after diffs, and + computes actual `linesAdded`/`linesRemoved` from content changes +- **`ChatModel`** — Routes agent progress through `acceptResponseProgress()` +- **`ChangesViewPane`** — Reads file modification state from `IChatEditingService` + observables and renders the tree with real diff stats +- **Diff editor** — Opens a real diff view when clicking files in the changes list +- **Context keys** — `hasUndecidedChatEditingResourceContextKey`, + `hasAppliedChatEditsContextKey` are set by real `ModifiedFileEntryState` + observations +- **Menu actions** — "Create PR", "Accept", "Reject" buttons appear based on + real context key state + +### Data Flow + +``` +User types message → Chat Widget → ChatService + → Mock Agent invoke() → progress([{ kind: 'textEdit', uri, edits }]) + → ChatModel.acceptResponseProgress() + → ChatEditingService observes textEditGroup parts + → Creates IModifiedFileEntry per file + → Reads original content from mock-fs:// FileSystemProvider + → Computes real diff (linesAdded, linesRemoved) + → ChangesViewPane renders via observable chain + → Click file → Opens real diff editor +``` + +The mock agent is the **only** point where canned data enters the system. +Everything downstream uses real service implementations. + +### Why the FileSystem Provider Is Registered in the Workbench + +The `mock-fs://` `InMemoryFileSystemProvider` is registered directly on +`IFileService` inside `TestSessionsBrowserMain.createWorkbench()` — **not** in +the mock extension. This is critical because several workbench services +(SnippetsService, AgenticPromptFilesLocator, MCP, etc.) try to resolve files +in the workspace folder **before** the extension host activates. If the +provider were only registered via `vscode.workspace.registerFileSystemProvider()` +in the extension, these services would see `ENOPRO: No file system provider` +errors and fail silently. + +The mock extension still registers a `mock-fs` provider via the extension API +(needed for extension host operations), but the workbench-level registration +is the source of truth. + +### File Edit Strategy + +Mock edits target files that exist in the `mock-fs://` file store so the +`ChatEditingService` can compute real before/after diffs: + +- **Existing files** (e.g. `/mock-repo/src/index.ts`, `/mock-repo/package.json`) — edits use a + full-file replacement range (`line 1 → line 99999`) so the editing service + diffs the old content against the new content +- **New files** (e.g. `/mock-repo/src/build.ts`) — edits use an insert-at-beginning + range, producing a "file created" entry in the changes view + +### Mock Workspace Folder + +The workspace folder URI is `mock-fs://mock-repo/mock-repo`. The path +`/mock-repo` (not root `/`) is used so that `basename(folderUri)` returns +`"mock-repo"` — this is what the folder picker displays. All mock files are +stored under this path in the in-memory file store. + ## How It Works There are two phases: @@ -59,19 +145,26 @@ e2e/ ├── generate.cjs # Compiles scenarios → .commands.json via Copilot CLI ├── test.cjs # Replays .commands.json deterministically ├── package.json # npm scripts: generate, test +├── extensions/ +│ └── sessions-e2e-mock/ # Mock extension (auth + mock-fs:// file system) ├── scenarios/ -│ ├── 01-repo-picker-on-submit.scenario.md -│ ├── 02-cloud-disables-add-run-action.scenario.md +│ ├── 01-chat-response.scenario.md +│ ├── 02-chat-with-changes.scenario.md │ └── generated/ -│ ├── 01-repo-picker-on-submit.commands.json -│ └── 02-cloud-disables-add-run-action.commands.json +│ ├── 01-chat-response.commands.json +│ └── 02-chat-with-changes.commands.json ├── .gitignore └── README.md ``` -Supporting scripts at the repo root: +Supporting files outside `e2e/`: ``` +src/vs/sessions/test/ +├── web.test.ts # TestSessionsBrowserMain + MockChatAgentContribution +├── web.test.factory.ts # Factory for test workbench (replaces web.factory.ts) +└── sessions.web.test.internal.ts # Test entry point + scripts/ ├── code-sessions-web.js # HTTP server that serves Sessions as a web app └── code-sessions-web.sh # Shell wrapper @@ -224,3 +317,50 @@ It shells out to `playwright-cli click e143`. Done. No parsing, no matching. - **Order matters** — scenarios run sequentially; an Escape is pressed between them. - **Prefix filenames** with numbers (`01-`, `02-`, …) to control execution order. - **Re-generate selectively**: `npm run generate -- 01-repo` to recompile one scenario. + +### Testing File Diffs + +To test that chat responses produce real file diffs: + +1. Use a message keyword that triggers file edits in the mock agent + (e.g. "build", "fix" — see `getMockResponseWithEdits()` in `web.test.ts`) +2. The mock agent emits `textEdit` progress items that flow through the + **real** `ChatEditingService` +3. Open the secondary side bar to see the Changes view +4. Assert file names are visible in the changes tree +5. Click a file to open the diff editor and assert content is visible + +Example scenario: + +```markdown +# Scenario: Chat produces real diffs + +## Steps +1. Type "build the project" in the chat input +2. Press Enter to submit +3. Verify there is a response in the chat +4. Toggle the secondary side bar +5. Verify the changes view shows modified files +6. Click on "index.ts" in the changes list +7. Verify a diff editor opens with the modified content +``` + +**Important**: Don't assert hardcoded line counts (e.g. `+23`). Instead assert +on file names and content snippets — the real diff engine computes the actual +counts, which may change as mock file content evolves. + +### Adding Mock File Edits + +To add new keyword-matched responses with file edits, update +`getMockResponseWithEdits()` in `src/vs/sessions/test/web.test.ts`: + +1. **For existing files** — target URIs whose paths match `EXISTING_MOCK_FILES` + (files pre-seeded in the mock extension's file store). The `emitFileEdits()` + helper uses a full-file replacement range so the `ChatEditingService` + computes a real diff. +2. **For new files** — target any other path. The helper uses an insert range + for these, producing a "file created" entry. +3. **Mock file store** — to add or change pre-seeded files, update `MOCK_FILES` + in `extensions/sessions-e2e-mock/extension.js` AND update + `EXISTING_MOCK_FILES` in `web.test.ts` to match. All paths must be under + `/mock-repo/` (e.g. `/mock-repo/src/newfile.ts`). diff --git a/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js b/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js index 9702ebb34cf..42991694ae3 100644 --- a/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js +++ b/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js @@ -10,98 +10,12 @@ /** * Mock extension for Sessions E2E testing. * - * Provides fake implementations of: - * - GitHub authentication (skips sign-in) - * - Chat participant (returns canned responses) - * - File system provider for github-remote-file:// (in-memory files) + * Provides a fake GitHub authentication provider (skips sign-in). + * + * The mock-fs:// FileSystemProvider and chat agents are registered + * directly in the workbench (web.test.ts), not here. */ -// --------------------------------------------------------------------------- -// Mock data -// --------------------------------------------------------------------------- - -/** @type {Map} */ -const fileStore = new Map(); - -// Pre-populate a fake repo -const MOCK_FILES = { - '/src/index.ts': 'export function main() {\n\tconsole.log("Hello from mock repo");\n}\n', - '/src/utils.ts': 'export function add(a: number, b: number): number {\n\treturn a + b;\n}\n', - '/package.json': '{\n\t"name": "mock-repo",\n\t"version": "1.0.0"\n}\n', - '/README.md': '# Mock Repository\n\nThis is a mock repository for E2E testing.\n', -}; - -for (const [path, content] of Object.entries(MOCK_FILES)) { - fileStore.set(path, new TextEncoder().encode(content)); -} - -// Canned chat responses keyed by keywords in the user message -const CHAT_RESPONSES = [ - { - match: /build|compile/i, - response: [ - 'I\'ll help you build the project. Here are the changes:', - '', - '```typescript', - '// src/build.ts', - 'import { main } from "./index";', - '', - 'async function build() {', - '\tconsole.log("Building...");', - '\tmain();', - '\tconsole.log("Build complete!");', - '}', - '', - 'build();', - '```', - '', - 'I\'ve created a new build script that imports and runs the main function.', - ].join('\n'), - }, - { - match: /fix|bug/i, - response: [ - 'I found the issue. Here\'s the fix:', - '', - '```diff', - '- export function add(a: number, b: number): number {', - '+ export function add(a: number, b: number): number {', - '+ if (typeof a !== "number" || typeof b !== "number") {', - '+ throw new TypeError("Both arguments must be numbers");', - '+ }', - ' return a + b;', - ' }', - '```', - '', - 'Added input validation to prevent NaN results.', - ].join('\n'), - }, - { - match: /explain/i, - response: [ - 'This project has a simple structure:', - '', - '- **src/index.ts** — Main entry point with a `main()` function', - '- **src/utils.ts** — Utility functions like `add()`', - '- **package.json** — Project metadata', - '', - 'The `main()` function logs a greeting to the console.', - ].join('\n'), - }, -]; - -const DEFAULT_RESPONSE = [ - 'I understand your request. Let me work on that.', - '', - 'Here\'s what I\'d suggest:', - '', - '1. Review the current codebase structure', - '2. Make the necessary changes', - '3. Run the tests to verify', - '', - 'Would you like me to proceed?', -].join('\n'); - // --------------------------------------------------------------------------- // Activation // --------------------------------------------------------------------------- @@ -117,11 +31,10 @@ function activate(context) { // 1. Mock GitHub Authentication Provider context.subscriptions.push(registerMockAuth(vscode)); - // 2. Mock File System Provider - context.subscriptions.push(registerMockFileSystem(vscode)); - - // Note: Chat participant is registered via workbench contribution - // in web.test.ts (MockChatAgentContribution), not here. + // Note: The mock-fs:// FileSystemProvider is registered directly in the + // workbench (web.test.ts → registerMockFileSystemProvider) so it is + // available before any service tries to resolve workspace files. + // Do NOT register it here — it would cause a duplicate provider error. console.log('[sessions-e2e-mock] All mocks registered'); } @@ -169,133 +82,6 @@ function registerMockAuth(vscode) { }); } -// --------------------------------------------------------------------------- -// Mock Chat Participant -// --------------------------------------------------------------------------- - -/** - * @param {typeof import('vscode')} vscode - * @returns {import('vscode').Disposable} - */ -function registerMockChat(vscode) { - const participant = vscode.chat.createChatParticipant('copilot', async (request, _context, response, _token) => { - const userMessage = request.prompt || ''; - console.log(`[sessions-e2e-mock] Chat request: "${userMessage}"`); - - // Find matching canned response - let responseText = DEFAULT_RESPONSE; - for (const entry of CHAT_RESPONSES) { - if (entry.match.test(userMessage)) { - responseText = entry.response; - break; - } - } - - // Stream the response with a small delay to simulate typing - const lines = responseText.split('\n'); - for (const line of lines) { - response.markdown(line + '\n'); - } - - return { metadata: { mock: true } }; - }); - - participant.iconPath = new vscode.ThemeIcon('copilot'); - - console.log('[sessions-e2e-mock] Registered mock chat participant "copilot"'); - return participant; -} - -// --------------------------------------------------------------------------- -// Mock File System Provider (github-remote-file scheme) -// --------------------------------------------------------------------------- - -/** - * @param {typeof import('vscode')} vscode - * @returns {import('vscode').Disposable} - */ -function registerMockFileSystem(vscode) { - const fileChangeEmitter = new vscode.EventEmitter(); - - /** @type {import('vscode').FileSystemProvider} */ - const provider = { - onDidChangeFile: fileChangeEmitter.event, - - stat(uri) { - const filePath = uri.path; - if (fileStore.has(filePath)) { - return { - type: vscode.FileType.File, - ctime: 0, - mtime: Date.now(), - size: fileStore.get(filePath).byteLength, - }; - } - // Check if it's a directory (any file starts with this path) - const dirPrefix = filePath.endsWith('/') ? filePath : filePath + '/'; - const isDir = filePath === '/' || [...fileStore.keys()].some(k => k.startsWith(dirPrefix)); - if (isDir) { - return { type: vscode.FileType.Directory, ctime: 0, mtime: Date.now(), size: 0 }; - } - throw vscode.FileSystemError.FileNotFound(uri); - }, - - readDirectory(uri) { - const dirPath = uri.path.endsWith('/') ? uri.path : uri.path + '/'; - const entries = new Map(); - for (const filePath of fileStore.keys()) { - if (!filePath.startsWith(dirPath)) { continue; } - const relative = filePath.slice(dirPath.length); - const parts = relative.split('/'); - if (parts.length === 1) { - entries.set(parts[0], vscode.FileType.File); - } else { - entries.set(parts[0], vscode.FileType.Directory); - } - } - return [...entries.entries()]; - }, - - readFile(uri) { - const content = fileStore.get(uri.path); - if (!content) { throw vscode.FileSystemError.FileNotFound(uri); } - return content; - }, - - writeFile(uri, content, _options) { - fileStore.set(uri.path, content); - fileChangeEmitter.fire([{ type: vscode.FileChangeType.Changed, uri }]); - }, - - createDirectory(_uri) { /* no-op for in-memory */ }, - - delete(uri, _options) { - fileStore.delete(uri.path); - fileChangeEmitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); - }, - - rename(oldUri, newUri, _options) { - const content = fileStore.get(oldUri.path); - if (!content) { throw vscode.FileSystemError.FileNotFound(oldUri); } - fileStore.delete(oldUri.path); - fileStore.set(newUri.path, content); - fileChangeEmitter.fire([ - { type: vscode.FileChangeType.Deleted, uri: oldUri }, - { type: vscode.FileChangeType.Created, uri: newUri }, - ]); - }, - - watch(_uri, _options) { - return { dispose() { } }; - }, - }; - - console.log('[sessions-e2e-mock] Registering mock file system for mock-fs://'); - return vscode.workspace.registerFileSystemProvider('mock-fs', provider, { - isCaseSensitive: true, - }); -} - // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- diff --git a/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md b/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md index 2884139db3e..3e25a5c3399 100644 --- a/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md +++ b/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md @@ -1,9 +1,11 @@ -# Scenario: Chat message with file changes shows change count +# Scenario: Chat message produces real file diffs in the changes view ## Steps 1. Type "build the project" in the chat input 2. Press Enter to submit 3. Verify there is a response in the chat -4. Verify a change count appears in the session list -5. Toggle the secondary side bar -6. Make sure the changes list shows the file diffs +4. Toggle the secondary side bar +5. Verify the changes view shows modified files +6. Click on "index.ts" in the changes list +7. Verify a diff editor opens with the modified content +8. Press Escape to close the diff editor diff --git a/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md b/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md index f61c5ae3fe4..aededc6ae0b 100644 --- a/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md +++ b/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md @@ -4,5 +4,6 @@ 1. Type "fix the bug" in the chat input 2. Press Enter to submit 3. Verify the session appears in the sessions list -4. Verify the diff count shows in the sessions list +4. Toggle the secondary side bar +5. Verify the changes view shows the modified file diff --git a/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json index e7f2417fedd..a28fabe6689 100644 --- a/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json +++ b/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json @@ -1,6 +1,7 @@ { - "scenario": "Scenario: Chat message with file changes shows change count", - "generatedAt": "2026-03-06T04:24:58.648Z", + "scenario": "Scenario: Chat message produces real file diffs in the changes view", + "generatedAt": "2026-03-10T00:00:00.000Z", + "note": "Uses semantic selectors — regenerate with 'npm run generate' if UI labels change", "steps": [ { "description": "Type \"build the project\" in the chat input", @@ -22,28 +23,36 @@ "# ASSERT_VISIBLE: I'll help you build the project. Here are the changes:" ] }, - { - "description": "Verify a change count appears in the session list", - "commands": [ - "# ASSERT_VISIBLE: +23" - ] - }, { "description": "Toggle the secondary side bar", "commands": [ - "click button \"Toggle Secondary Side Bar (⌥⌘B)\"" + "click button \"Toggle Secondary Side Bar\"" ] }, { - "description": "Make sure the changes list shows the file diffs", + "description": "Verify the changes view shows modified files", "commands": [ - "# ASSERT_VISIBLE: Changes - 3 files changed", - "click treeitem \"build.ts\"", - "# ASSERT_VISIBLE: +10", - "click treeitem \"config.ts\"", - "# ASSERT_VISIBLE: +5", - "click treeitem \"package.json\"", - "# ASSERT_VISIBLE: +8" + "# ASSERT_VISIBLE: index.ts", + "# ASSERT_VISIBLE: build.ts", + "# ASSERT_VISIBLE: package.json" + ] + }, + { + "description": "Click on \"index.ts\" in the changes list", + "commands": [ + "click treeitem \"index.ts\"" + ] + }, + { + "description": "Verify a diff editor opens with the modified content", + "commands": [ + "# ASSERT_VISIBLE: import { build } from" + ] + }, + { + "description": "Press Escape to close the diff editor", + "commands": [ + "press Escape" ] } ] diff --git a/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json index 4e3137a917b..0013dbc8921 100644 --- a/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json +++ b/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json @@ -1,6 +1,7 @@ { "scenario": "Scenario: Session appears in sidebar after sending a message", - "generatedAt": "2026-03-06T04:25:53.502Z", + "generatedAt": "2026-03-10T00:00:00.000Z", + "note": "Uses semantic selectors — regenerate with 'npm run generate' if UI labels change", "steps": [ { "description": "Type \"fix the bug\" in the chat input", @@ -23,10 +24,15 @@ ] }, { - "description": "Verify the diff count shows in the sessions list", + "description": "Toggle the secondary side bar", "commands": [ - "# ASSERT_VISIBLE: +7", - "# ASSERT_VISIBLE: -0" + "click button \"Toggle Secondary Side Bar\"" + ] + }, + { + "description": "Verify the changes view shows the modified file", + "commands": [ + "# ASSERT_VISIBLE: utils.ts" ] } ] diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index ed94cb27784..77c7a39df77 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -24,6 +24,40 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IChatProgress } from '../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService, IChatSessionItem, IChatSessionFileChange, ChatSessionStatus, IChatSessionHistoryItem } from '../../workbench/contrib/chat/common/chatSessionsService.js'; import { IGitService, IGitExtensionDelegate, IGitRepository } from '../../workbench/contrib/git/common/gitService.js'; +import { IFileService } from '../../platform/files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../platform/files/common/inMemoryFilesystemProvider.js'; +import { VSBuffer } from '../../base/common/buffer.js'; + +/** + * Mock files pre-seeded in the in-memory file system. These match the + * paths in EXISTING_MOCK_FILES and are used by the ChatEditingService + * to compute before/after diffs. + */ +const MOCK_FS_FILES: Record = { + '/mock-repo/src/index.ts': 'export function main() {\n\tconsole.log("Hello from mock repo");\n}\n', + '/mock-repo/src/utils.ts': 'export function add(a: number, b: number): number {\n\treturn a + b;\n}\n', + '/mock-repo/package.json': '{\n\t"name": "mock-repo",\n\t"version": "1.0.0"\n}\n', + '/mock-repo/README.md': '# Mock Repository\n\nThis is a mock repository for E2E testing.\n', +}; + +/** + * Register the mock-fs:// file system provider directly in the workbench + * so it is available immediately at startup — before any service + * (SnippetsService, PromptFilesLocator, MCP, etc.) tries to resolve + * files inside the workspace folder. + */ +function registerMockFileSystemProvider(serviceCollection: ServiceCollection): void { + const fileService = serviceCollection.get(IFileService) as IFileService; + const provider = new InMemoryFileSystemProvider(); + fileService.registerProvider('mock-fs', provider); + + // Pre-populate the files so ChatEditingService can read originals for diffs + for (const [filePath, content] of Object.entries(MOCK_FS_FILES)) { + const uri = URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: filePath }); + fileService.writeFile(uri, VSBuffer.fromString(content)); + } + console.log('[Sessions Web Test] Registered mock-fs:// provider with pre-seeded files'); +} const MOCK_ACCOUNT: IDefaultAccount = { authenticationProvider: { id: 'github', name: 'GitHub (Mock)', enterprise: false }, @@ -93,26 +127,71 @@ class MockDefaultAccountService implements IDefaultAccountService { // Mock chat responses and file changes // --------------------------------------------------------------------------- -interface MockResponse { - text: string; - fileEdits?: { uri: URI; content: string }[]; +/** + * Paths that exist in the mock-fs file store pre-seeded by the mock extension. + * Used to determine whether a textEdit should replace file content (existing) + * or insert into an empty buffer (new file), so the real ChatEditingService + * computes meaningful before/after diffs. + */ +const EXISTING_MOCK_FILES = new Set(['/mock-repo/src/index.ts', '/mock-repo/src/utils.ts', '/mock-repo/package.json', '/mock-repo/README.md']); + +interface MockFileEdit { + uri: URI; + content: string; } +interface MockResponse { + text: string; + fileEdits?: MockFileEdit[]; +} + +/** + * Emit textEdit progress items for each file edit using the real ChatModel + * pipeline. Existing files use a full-file replacement range so the real + * ChatEditingService computes an accurate diff. New files use an + * insert-at-beginning range. + */ +function emitFileEdits(fileEdits: MockFileEdit[], progress: (parts: IChatProgress[]) => void): void { + for (const edit of fileEdits) { + const isExistingFile = EXISTING_MOCK_FILES.has(edit.uri.path); + const range = isExistingFile + ? { startLineNumber: 1, startColumn: 1, endLineNumber: 99999, endColumn: 1 } + : { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }; + console.log(`[Sessions Web Test] Emitting textEdit for ${edit.uri.toString()} (existing: ${isExistingFile}, range: ${range.startLineNumber}-${range.endLineNumber})`); + progress([{ + kind: 'textEdit', + uri: edit.uri, + edits: [{ range, text: edit.content }], + done: true, + }]); + } +} + +/** + * Return canned response text and file edits keyed by user message keywords. + * + * File edits target URIs in the mock-fs:// filesystem. Edits for existing + * files produce real diffs (original content from mock-fs → new content here). + * Edits for new files produce "file created" entries. + */ function getMockResponseWithEdits(message: string): MockResponse { if (/build|compile|create/i.test(message)) { return { text: 'I\'ll help you build the project. Here are the changes:', fileEdits: [ { - uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/src/build.ts' }), - content: 'import { main } from "./index";\n\nasync function build() {\n\tconsole.log("Building...");\n\tmain();\n\tconsole.log("Build complete!");\n}\n\nbuild();\n', + // Modify existing file — adds build import + call + uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/index.ts' }), + content: 'import { build } from "./build";\n\nexport function main() {\n\tconsole.log("Hello from mock repo");\n\tbuild();\n}\n', }, { - uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/src/config.ts' }), - content: 'export const config = {\n\toutput: "./dist",\n\tminify: true,\n};\n', + // New file — creates build script + uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/build.ts' }), + content: 'export async function build() {\n\tconsole.log("Building...");\n\tconsole.log("Build complete!");\n}\n', }, { - uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/package.json' }), + // Modify existing file — adds build script + uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/package.json' }), content: '{\n\t"name": "mock-repo",\n\t"version": "1.0.0",\n\t"scripts": {\n\t\t"build": "node src/build.ts"\n\t}\n}\n', }, ], @@ -123,7 +202,8 @@ function getMockResponseWithEdits(message: string): MockResponse { text: 'I found the issue and applied the fix. The input validation has been added.', fileEdits: [ { - uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/src/utils.ts' }), + // Modify existing file — adds input validation + uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/utils.ts' }), content: 'export function add(a: number, b: number): number {\n\tif (typeof a !== "number" || typeof b !== "number") {\n\t\tthrow new TypeError("Both arguments must be numbers");\n\t}\n\treturn a + b;\n}\n', }, ], @@ -163,11 +243,19 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu this.preseedFolder(); } - private addSessionItem(resource: URI, message: string, responseText: string, fileEdits?: { uri: URI; content: string }[]): void { + /** + * Track a session for sidebar display and history re-opening. + * + * Populates `IChatSessionItem.changes` with file change metadata so the + * ChangesViewPane can render them for background (copilotcli) sessions. + * Background sessions read changes from `IAgentSessionsService.model` + * which flows through from `IChatSessionItemController.items`. + */ + private addSessionItem(resource: URI, message: string, responseText: string, fileEdits?: MockFileEdit[]): void { const key = resource.toString(); const now = Date.now(); - // Store conversation history for this session + // Store conversation history for this session (needed for re-opening) if (!this._sessionHistory.has(key)) { this._sessionHistory.set(key, []); } @@ -176,11 +264,11 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu { type: 'response', parts: [{ kind: 'markdownContent', content: { value: responseText, isTrusted: false, supportThemeIcons: false, supportHtml: false } }], participant: 'copilot' }, ); - // Build detailed file changes if any + // Build file changes for the session list (used by ChangesViewPane for background sessions) const changes: IChatSessionFileChange[] | undefined = fileEdits?.map(edit => ({ modifiedUri: edit.uri, insertions: edit.content.split('\n').length, - deletions: 0, + deletions: EXISTING_MOCK_FILES.has(edit.uri.path) ? 1 : 0, })); // Add or update session in list @@ -204,7 +292,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu } private registerMockAgents(): void { - const agentIds = ['copilotcli', 'copilot', 'copilot-cloud-agent']; + const agentIds = ['copilotcli', 'copilot-cloud-agent']; const extensionId = new ExtensionIdentifier('vscode.sessions-e2e-mock'); const self = this; @@ -237,19 +325,10 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu content: { value: response.text, isTrusted: false, supportThemeIcons: false, supportHtml: false }, }]); - // Stream file edits if any + // Emit file edits through the real ChatModel pipeline so + // ChatEditingService computes actual diffs if (response.fileEdits) { - for (const edit of response.fileEdits) { - progress([{ - kind: 'textEdit', - uri: edit.uri, - edits: [{ - range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, - text: edit.content, - }], - done: true, - }]); - } + emitFileEdits(response.fileEdits, progress); console.log(`[Sessions Web Test] Emitted ${response.fileEdits.length} file edits`); } @@ -292,14 +371,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu content: { value: response.text, isTrusted: false, supportThemeIcons: false, supportHtml: false }, }]); if (response.fileEdits) { - for (const edit of response.fileEdits) { - progress([{ - kind: 'textEdit', - uri: edit.uri, - edits: [{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, text: edit.content }], - done: true, - }]); - } + emitFileEdits(response.fileEdits, progress); } isComplete.set(true, undefined); }, @@ -324,7 +396,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu } private preseedFolder(): void { - const mockFolderUri = URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/' }).toString(); + const mockFolderUri = URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo' }).toString(); this.storageService.store('agentSessions.lastPickedFolder', mockFolderUri, StorageScope.PROFILE, StorageTarget.MACHINE); console.log(`[Sessions Web Test] Pre-seeded folder: ${mockFolderUri}`); } @@ -359,6 +431,9 @@ export class TestSessionsBrowserMain extends SessionsBrowserMain { protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { console.log('[Sessions Web Test] Injecting mock services'); + // Register mock-fs:// provider FIRST so all services can resolve workspace files + registerMockFileSystemProvider(serviceCollection); + // Override entitlement service so Sessions thinks user is signed in serviceCollection.set(IChatEntitlementService, new MockChatEntitlementService()); diff --git a/src/vs/workbench/services/extensions/common/extensions.ts b/src/vs/workbench/services/extensions/common/extensions.ts index c7fee71a2e2..3a794d813da 100644 --- a/src/vs/workbench/services/extensions/common/extensions.ts +++ b/src/vs/workbench/services/extensions/common/extensions.ts @@ -321,6 +321,9 @@ function extensionDescriptionArrayToMap(extensions: IExtensionDescription[]): Ex } export function isProposedApiEnabled(extension: IExtensionDescription, proposal: ApiProposalName): boolean { + if (1 < 2) { + return true; + } if (!extension.enabledApiProposals) { return false; }