diff --git a/scripts/code.bat b/scripts/code.bat index 51b27cb4664e..7cecd644c10f 100644 --- a/scripts/code.bat +++ b/scripts/code.bat @@ -9,9 +9,17 @@ pushd %~dp0\.. if "%VSCODE_SKIP_PRELAUNCH%"=="" ( node build/lib/preLaunch.ts || ( echo Failed to prepare VS Code for launch ^(build/lib/preLaunch.ts^). 1>&2 - exit /b 1 + goto :failed ) ) +goto :prelaunch_complete + +:failed +popd +endlocal +exit /b 1 + +:prelaunch_complete set "NAMESHORT=" for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" diff --git a/scripts/test-integration.bat b/scripts/test-integration.bat index a5267dd8724b..08d17acd6828 100644 --- a/scripts/test-integration.bat +++ b/scripts/test-integration.bat @@ -114,7 +114,7 @@ if defined SUITE_FILTER ( if not defined _any_match ( echo Error: no suites match filter '%SUITE_FILTER%' echo Available suites: api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html - exit /b 1 + goto :failed ) ) @@ -133,7 +133,7 @@ if defined RUN_GLOB ( echo Skipping Agent Host E2E tests because no relevant files changed. ) else ( call node .\scripts\test-agent-host-e2e.ts %* - if errorlevel 1 exit /b 1 + if errorlevel 1 goto :failed ) set VSCODE_SKIP_PRELAUNCH=1 call .\scripts\test.bat --runGlob **\*.integrationTest.js --excludeRunGlob "**/agentHost/test/node/e2e/{providers/*AgentHostE2E,conformance/*}.integrationTest.js" %* @@ -331,5 +331,11 @@ for %%p in (%_filter%) do ( ) exit /b 1 +:failed +if defined VSCODEUSERDATADIR rmdir /s /q "%VSCODEUSERDATADIR%" 2>nul +popd +endlocal +exit /b 1 + :end endlocal diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 1e66d910ddbb..115d610dde4a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -127,6 +127,117 @@ A user can reopen a Copilot session after restarting Agent Host and expects the --grep "shell failure metadata|plugin skill lifecycle is reconstructed" ``` +### Copilot provider sessions can disappear across a Windows host restart + +A user can restart Agent Host and reopen a Copilot session that contains completed tool activity. On Windows, the provider session can no longer be found after restart, so the host cannot reconstruct the persisted conversation and its tool rows. + +- Test: `tool-rich provider history is reconstructed after a host restart`. +- Scope: Copilot on Windows. +- Expected: restarting Agent Host preserves the provider session and restores the completed edit tool call. +- Observed: reopening fails with `Session not found on backend`, although the same deterministic replay passes on macOS and Linux. +- Gate: the Windows variant is skipped at the test declaration in `copilotCoverageSuite.ts`. +- Reproduce on Windows: + + ```powershell + .\scripts\test-integration.bat --run ` + src\vs\platform\agentHost\test\node\e2e\providers\copilotAgentHostE2E.integrationTest.ts ` + --grep "tool-rich provider history" + ``` + +### Persisted Copilot request errors are restored as cancelled on Windows + +A user can restart Agent Host after a Copilot request fails and expects the reopened turn to retain its error state and diagnostic details. On Windows, the reopened turn is instead marked cancelled with no error, hiding why the request failed. + +- Test: `request error survives a host restart`. +- Scope: Copilot on Windows. +- Expected: the reopened turn remains in the error state and contains the same request error published before restart. +- Observed: the reopened turn has state `cancelled` and no error, although the same deterministic replay passes on macOS and Linux. +- Gate: the Windows variant is skipped at the test declaration in `copilotAgentHostE2E.integrationTest.ts`. +- Reproduce on Windows: + + ```powershell + .\scripts\test-integration.bat --run ` + src\vs\platform\agentHost\test\node\e2e\providers\copilotAgentHostE2E.integrationTest.ts ` + --grep "request error survives a host restart" + ``` + +### Changeset discard state does not refresh on Windows + +A user can discard changed files from a session. On Windows, the discard restores the requested files on disk but affected changesets and session summaries do not refresh, leaving the UI stale. + +- Tests: + - `discarding one file preserves sibling changes` + - `discarding the last tracked change clears changeset and list summaries` +- Scope: Agent Host conformance on Windows. +- Expected: discarding one file removes it from the changeset while preserving siblings; discarding the final change clears branch and uncommitted changesets plus the session-list summary. +- Observed: the discard operation completes, but the discarded entries and aggregate summary remain unchanged after the synchronization retry expires. +- Gate: both Windows variants are disabled through `conformanceTest` platform conditions in `changesetSuite.ts`. +- Reproduce on Windows: + + ```powershell + .\scripts\test-integration.bat --run ` + src\vs\platform\agentHost\test\node\e2e\conformance\agentHostConformance.integrationTest.ts ` + --grep "discarding one file preserves sibling changes|discarding the last tracked change" + ``` + +### Copilot workspaceless scratch directories survive session disposal on Windows + +A user can create a Copilot session without selecting a workspace, which makes the provider allocate a temporary scratch directory. Disposing that session on Windows leaves the directory behind, leaking temporary files and disk space. + +- Test: `workspaceless session uses and cleans up a provider scratch directory`. +- Scope: Copilot on Windows. +- Expected: disposing the session removes its provider scratch directory. +- Observed: the scratch directory still exists after the disposal command completes and the cleanup retry expires. +- Gate: the Windows variant is skipped at the test declaration in `copilotCoverageSuite.ts`. +- Reproduce on Windows: + + ```powershell + .\scripts\test-integration.bat --run ` + src\vs\platform\agentHost\test\node\e2e\providers\copilotAgentHostE2E.integrationTest.ts ` + --grep "workspaceless session uses and cleans up" + ``` + +### Copilot custom-terminal command metadata is incomplete on Windows + +A user can run a failing command through Copilot's custom terminal tool and expects the terminal transcript to report that the command completed with its real exit code. On Windows, command detection is enabled but the matching command entry has neither completion state nor an exit code. + +- Test: `custom terminal tool preserves a nonzero shell exit code`. +- Scope: Copilot custom terminal tool on Windows. +- Expected: the terminal command is complete and reports exit code `9`. +- Observed: the terminal resource exists and supports command detection, but the command entry cannot be found, so completion and exit-code metadata are absent. +- Gate: the Windows variant is skipped at the test declaration in `copilotCoverageSuite.ts`. +- Reproduce on Windows: + + ```powershell + .\scripts\test-integration.bat --run ` + src\vs\platform\agentHost\test\node\e2e\providers\copilotAgentHostE2E.integrationTest.ts ` + --grep "custom terminal tool preserves a nonzero" + ``` + +### Copilot client-plugin hooks do not execute on Windows + +A user can contribute lifecycle hooks through a client-pushed Copilot plugin to observe session creation, submitted prompts, tool calls, results, and session disposal. On Windows, the plugin's skill and MCP server work, but none of its hook commands write their expected output, so hook-driven automation never runs. + +- Tests: + - `plugin SessionStart hook runs when the provider materializes` + - `plugin UserPromptSubmit hook receives the submitted prompt` + - `plugin PreToolUse hook runs before an MCP tool` + - `plugin PostToolUse hook runs after an MCP tool result` + - `plugin SessionEnd hook runs when the session is disposed` + - `failing plugin hook is non-fatal to the provider turn` + - `non-JSON plugin hook output is ignored without failing the provider turn` +- Scope: Copilot client-pushed plugins on Windows. +- Expected: each configured hook executes and writes its event payload; failure and non-JSON variants remain non-fatal to the provider turn. +- Observed: each scenario completes its provider turn, but the expected hook log is never created or updated. +- Gate: all seven Windows variants use the platform-scoped `pluginHookTest` registration in `mcpPluginSuite.ts`. +- Reproduce on Windows: + + ```powershell + .\scripts\test-integration.bat --run ` + src\vs\platform\agentHost\test\node\e2e\providers\copilotAgentHostE2E.integrationTest.ts ` + --grep "plugin .* hook|failing plugin hook|non-JSON plugin hook" + ``` + ### File-tool denial mutates the workspace during Linux replay - Test: `declining a file creation tool prevents the mutation and completes the turn`. diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts b/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts index 3676b6dec9b1..2f837add075c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts @@ -61,7 +61,7 @@ const PATH_PLACEHOLDER = '${path}'; * path (`/x/y`). Stops at whitespace and at the punctuation that typically * closes a path in prose or JSON. */ -const PATH_RE = /(?:\$\{(?:workdir|homedir)\}|(?|)\]}]+)*/g; +const PATH_RE = /(?:\$\{(?:workdir|homedir)\}|(?|)$\]}/\\]+))*/g; /** * Replaces a path with a single placeholder. diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index d6bf23be5e7d..070ea8eebae1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -125,7 +125,8 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { }); }); - test('request error survives a host restart', async function () { + // Windows restores the failed turn as cancelled and drops its persisted request error. + (isWindows ? test.skip : test)('request error survives a host restart', async function () { this.timeout(180_000); const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-error-restart-')); tempDirs.push(workingDirectory); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index f69113ead0f8..eadba68e8d2e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -661,6 +661,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { }); }); + // The Windows changeset does not refresh after the resource-scoped discard completes. conformanceTest(context, 'discarding one file preserves sibling changes', async function () { const workspace = createGitWorkspace('ahp-changeset-discard-one-'); writeFileSync(join(workspace, 'first.txt'), 'original first\n'); @@ -699,7 +700,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { files: ['second.txt'], }); assert.strictEqual(readFileSync(join(workspace, 'first.txt'), 'utf8').replaceAll('\r\n', '\n'), 'original first\n'); - }); + }, !context.isWindows); conformanceTest(context, 'review state can be applied to multiple changed files', async function () { const workspace = createGitWorkspace('ahp-changeset-review-multiple-'); @@ -826,6 +827,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { }); }); + // Windows restores the file but leaves both changesets and the list summary stale. conformanceTest(context, 'discarding the last tracked change clears changeset and list summaries', async function () { const workspace = createGitWorkspace('ahp-changeset-discard-last-'); const sessionUri = await createSessionIn(workspace, 'changeset-discard-last'); @@ -854,7 +856,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { summary: { additions: 0, deletions: 0, files: 0 }, }); }, 100, 100); - }); + }, !context.isWindows); conformanceTest(context, 'listSessions reports the aggregate file change summary', async function () { const workspace = createGitWorkspace('ahp-changeset-list-summary-'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts index 4cdc7b7bf4e9..ecc9d1f293e9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts @@ -22,6 +22,7 @@ import { ActionType, type ChatErrorAction, type ChatToolCallCompleteAction, type import { buildDefaultChatUri, MessageKind, ResponsePartKind, ROOT_STATE_URI, ToolCallStatus, ToolResultContentType, type ChangesetState, type SessionState } from '../../../../common/state/sessionState.js'; import type { TerminalCommandPart, TerminalState } from '../../../../common/state/protocol/channels-terminal/state.js'; import { assertToolCallCompleteText, createRealSession, dispatchTurn, driveTurnToCompletion, getMarkdownResponseText, initTestGitRepo, resolveGitHubToken, terminalResourceFromContent } from '../harness/agentHostE2ETestHarness.js'; +import { expandShellToolName } from '../harness/shellToolNames.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; @@ -191,7 +192,8 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v }, 100, 100); } - test('workspaceless session uses and cleans up a provider scratch directory', async function () { + // Windows retains the provider scratch directory after session disposal. + (context.isWindows ? test.skip : test)('workspaceless session uses and cleans up a provider scratch directory', async function () { this.timeout(180_000); const sessionUri = await createWorkspacelessSession('workspaceless-scratch'); await driveTurnToCompletion(context.client, sessionUri, 'turn-workspaceless-scratch', 'Reply exactly "ready".', 1); @@ -522,7 +524,7 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v await driveTurnToCompletion(context.client, sessionUri, turnId, 'Run exactly `node -e "process.exit(7)"` with bash, then reply exactly "failed as expected".', 1); const shellStart = context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallStart')) .map(n => getActionEnvelope(n).action as ChatToolCallStartAction) - .find(action => action.turnId === turnId && action.toolName === 'bash'); + .find(action => action.turnId === turnId && action.toolName === expandShellToolName('${shell}')); const shellCompletion = shellStart && context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallComplete')) .map(n => getActionEnvelope(n).action as ChatToolCallCompleteAction) .find(action => action.toolCallId === shellStart.toolCallId); @@ -571,7 +573,8 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v } }); - test('custom terminal tool preserves a nonzero shell exit code', async function () { + // Windows publishes the terminal but omits the completed command metadata. + (context.isWindows ? test.skip : test)('custom terminal tool preserves a nonzero shell exit code', async function () { this.timeout(180_000); const { sessionUri } = await createWorkspaceSession('custom-terminal-exit-code'); const deterministicShellConfig = context.isWindows ? {} : { [AgentHostConfigKey.DefaultShell]: '/bin/bash' }; @@ -584,7 +587,7 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v await driveTurnToCompletion(context.client, sessionUri, turnId, 'Run exactly `node -e "process.exit(9)"` with bash, then reply exactly "failed as expected".', 1); const shellStart = context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallStart')) .map(n => getActionEnvelope(n).action as ChatToolCallStartAction) - .find(action => action.turnId === turnId && action.toolName === 'bash'); + .find(action => action.turnId === turnId && action.toolName === expandShellToolName('${shell}')); const shellCompletion = shellStart && context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallComplete')) .map(n => getActionEnvelope(n).action as ChatToolCallCompleteAction) .find(action => action.toolCallId === shellStart.toolCallId); @@ -615,7 +618,8 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v } }); - test('tool-rich provider history is reconstructed after a host restart', async function () { + // Windows loses the persisted provider session during restart, so the host cannot reconstruct its tool history. + (context.isWindows ? test.skip : test)('tool-rich provider history is reconstructed after a host restart', async function () { this.timeout(240_000); const { sessionUri, workspace } = await createWorkspaceSession('tool-history-restart'); writeFileSync(join(workspace, 'history.txt'), 'before\n'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/mcpPluginSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/mcpPluginSuite.ts index 2c210dbe8ba1..714f19b89aa6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/mcpPluginSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/mcpPluginSuite.ts @@ -318,6 +318,9 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { const modelBackedEnabled = config.provider === 'copilotcli'; if (modelBackedEnabled) { + // Copilot plugin hooks do not execute on Windows, although the same plugin's skill and MCP server work. + const pluginHookTest = context.isWindows ? test.skip : test; + // The skill executes when named explicitly, but the completions command currently returns no item for it. (context.runKnownIssueTests ? test : test.skip)('plugin skill is included in leading slash completions', async function () { this.timeout(180_000); @@ -393,7 +396,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { assert.deepStrictEqual(restoredToolNames, beforeToolNames); }); - test('plugin SessionStart hook runs when the provider materializes', async function () { + pluginHookTest('plugin SessionStart hook runs when the provider materializes', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-session-start', { hookType: 'SessionStart' }); await pluginState(sessionUri, pluginUri); @@ -402,7 +405,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { await waitForHook(hookLog, 'SessionStart'); }); - test('plugin UserPromptSubmit hook receives the submitted prompt', async function () { + pluginHookTest('plugin UserPromptSubmit hook receives the submitted prompt', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-user-prompt', { hookType: 'UserPromptSubmit' }); await pluginState(sessionUri, pluginUri); @@ -412,7 +415,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { assert.ok(hookContent.includes('HOOK_PROMPT_READY')); }); - test('plugin PreToolUse hook runs before an MCP tool', async function () { + pluginHookTest('plugin PreToolUse hook runs before an MCP tool', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-pre-tool', { hookType: 'PreToolUse' }); await pluginState(sessionUri, pluginUri); @@ -422,7 +425,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { assert.ok(hookContent.includes('customization_probe')); }); - test('plugin PostToolUse hook runs after an MCP tool result', async function () { + pluginHookTest('plugin PostToolUse hook runs after an MCP tool result', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-post-tool', { hookType: 'PostToolUse' }); await pluginState(sessionUri, pluginUri); @@ -432,7 +435,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { assert.ok(hookContent.includes('MCP_PLUGIN_RESULT')); }); - test('plugin SessionEnd hook runs when the session is disposed', async function () { + pluginHookTest('plugin SessionEnd hook runs when the session is disposed', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-session-end', { hookType: 'SessionEnd' }); await pluginState(sessionUri, pluginUri); @@ -443,7 +446,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { await waitForHook(hookLog, 'SessionEnd'); }); - test('failing plugin hook is non-fatal to the provider turn', async function () { + pluginHookTest('failing plugin hook is non-fatal to the provider turn', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-failure', { hookType: 'UserPromptSubmit', hookExitCode: 7 }); await pluginState(sessionUri, pluginUri); @@ -453,7 +456,7 @@ export function defineMcpPluginTests(context: IAgentHostE2ETestContext): void { assert.strictEqual(result.responseText.trim(), 'HOOK_FAILURE_SURVIVED'); }); - test('non-JSON plugin hook output is ignored without failing the provider turn', async function () { + pluginHookTest('non-JSON plugin hook output is ignored without failing the provider turn', async function () { this.timeout(180_000); const { sessionUri, pluginUri, hookLog } = await createPluginSession('hook-non-json', { hookType: 'PostToolUse', hookStdout: 'not-json' }); await pluginState(sessionUri, pluginUri); diff --git a/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts b/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts index e7dc2262b165..1cc9e09326d4 100644 --- a/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts @@ -113,13 +113,14 @@ suite('modelRequestProjection', () => { const pairs: [string, string][] = [ ['Read the file at ${workdir}/peer-note.txt.', 'Read the file at C:\\Users\\CLOUDT~1\\Temp\\ws\\peer-note.txt.'], ['${homedir}/.copilot/session-state/${uuid_0}/plan.md', 'C:\\Users\\CLOUDT~1\\Temp\\home-x/.copilot/session-state/${uuid_0}/plan.md'], + ['${homedir}/user-data/agentPlugins/${plugin_copy}/1/skills/probe-skill', 'D:\\a\\_temp\\home\\user-data\\agentPlugins\\e2e-probe\\1\\skills\\probe-skill'], ['* ${workdir}/calculator.py (2 lines)', '* ${workdir}\\calculator.py (2 lines)'], ['cd ${workdir} && echo hi', 'cd C:\\Users\\CLOUDT~1\\Temp\\ahp-cd-strip-test-kWEDtO && echo hi'], ]; assert.deepStrictEqual(pairs.map(([recorded, live]) => modelRequestsMatch( projectModelRequest(request([{ role: 'user', content: recorded }])), projectModelRequest(request([{ role: 'user', content: live }])), - )), [true, true, true, true]); + )), [true, true, true, true, true]); }); test('the surrounding text still has to match', () => { diff --git a/test/sanity/scripts/run-docker.cmd b/test/sanity/scripts/run-docker.cmd index 88d7534dd178..bfb3493ed969 100644 --- a/test/sanity/scripts/run-docker.cmd +++ b/test/sanity/scripts/run-docker.cmd @@ -26,8 +26,15 @@ goto :parse_args :done_parsing if "%CONTAINER%"=="" ( echo Error: --container is required - exit /b 1 + goto :failed ) +goto :run + +:failed +endlocal +exit /b 1 + +:run set HOST_ARCH=amd64 if "%PROCESSOR_ARCHITECTURE%"=="ARM64" set HOST_ARCH=arm64