diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md index 197aed521d58c7..2775b1d4c70045 100644 --- a/.agents/skills/launch/SKILL.md +++ b/.agents/skills/launch/SKILL.md @@ -37,6 +37,8 @@ The clone is **slim**: workspace storage, browser caches, file history, cached V > The launcher always sets `files.simpleDialog.enable: true` in the launched profile's `User/settings.json`. This is required for automation: VS Code's native OS file dialogs cannot be driven via `@playwright/cli` over CDP and are completely unreachable over SSH on headless macOS. The simple (quick-input) dialog can be navigated with `press` and clipboard paste. The override is per-launch and only affects throwaway profiles. +> When launching a regular editor window from an agent session, first call `get_current_session` and pass its `title` as `--session-title`. The launcher writes that title into the throwaway profile's `window.title` setting so each editor window can be mapped back to its originating session. This never modifies the source profile. Do not combine `--session-title` with `--agents`: `window.title` is read-only in the Agents window. + > For unattended automation, pass `--disable-workspace-trust` so a trust dialog cannot block the flow or extension-host startup. The override is process-scoped and does not modify the source profile. Only use it with content you trust. ## Launch @@ -45,8 +47,9 @@ The launcher script lives next to this SKILL.md at `scripts/launch.sh` (macOS/Li ```bash # LAUNCH=/scripts/launch.sh -"$LAUNCH" # default: workbench -"$LAUNCH" --agents # Agents window +SESSION_TITLE= +"$LAUNCH" --session-title "$SESSION_TITLE" # default: workbench +"$LAUNCH" --agents # Agents window (no custom title) "$LAUNCH" -- # forward extra args to code.sh "$LAUNCH" --source-user-data-dir # pick a specific authed profile "$LAUNCH" --repo # if not run from the repo @@ -61,8 +64,9 @@ On Windows, invoke the PowerShell launcher with the same flags: ```powershell $skillDir = '' $launch = Join-Path $skillDir 'scripts\launch.ps1' -& $launch # default: workbench -& $launch --agents # Agents window +$sessionTitle = '' +& $launch --session-title $sessionTitle # default: workbench +& $launch --agents # Agents window (no custom title) & $launch -- --use-mock-keychain # forward extra args to code.bat & $launch --source-user-data-dir C:\path\to\profile & $launch --repo C:\path\to\vscode diff --git a/.agents/skills/launch/scripts/launch.ps1 b/.agents/skills/launch/scripts/launch.ps1 index b99ea4efa50995..51fd602aa72ffd 100644 --- a/.agents/skills/launch/scripts/launch.ps1 +++ b/.agents/skills/launch/scripts/launch.ps1 @@ -18,6 +18,7 @@ $cloneExtensions = $false $full = $false $skipPreLaunch = $false $disableWorkspaceTrust = $false +$sessionTitle = '' if ($null -eq $cliArgs) { $cliArgs = @() } @@ -279,103 +280,6 @@ function Test-SourceHasGitHubAuthenticationSecret([string]$node, [string]$source return $false } -function Get-JsoncCodeMask([string]$text) { - # Returns a same-length copy of $text with every comment span blanked out. - # Offsets are preserved so a match found in the mask can be applied to the - # original. String contents are respected, so a `//` inside a value (a URL, - # say) is not mistaken for a comment. - $chars = $text.ToCharArray() - $masked = [char[]]::new($chars.Length) - [Array]::Copy($chars, $masked, $chars.Length) - - $inString = $false - $inLineComment = $false - $inBlockComment = $false - $escaped = $false - - for ($i = 0; $i -lt $chars.Length; $i++) { - $current = $chars[$i] - $next = if ($i + 1 -lt $chars.Length) { $chars[$i + 1] } else { [char]0 } - - if ($inLineComment) { - if ($current -eq "`n") { $inLineComment = $false } else { $masked[$i] = ' ' } - continue - } - if ($inBlockComment) { - if ($current -eq '*' -and $next -eq '/') { - $masked[$i] = ' ' - $masked[$i + 1] = ' ' - $i++ - $inBlockComment = $false - } elseif ($current -ne "`n") { - $masked[$i] = ' ' - } - continue - } - if ($inString) { - if ($escaped) { $escaped = $false } - elseif ($current -eq '\') { $escaped = $true } - elseif ($current -eq '"') { $inString = $false } - continue - } - - if ($current -eq '"') { $inString = $true } - elseif ($current -eq '/' -and $next -eq '/') { $masked[$i] = ' '; $inLineComment = $true } - elseif ($current -eq '/' -and $next -eq '*') { $masked[$i] = ' '; $masked[$i + 1] = ' '; $i++; $inBlockComment = $true } - } - - return (-join $masked) -} - -function Ensure-SimpleDialogSetting([string]$settingsFile) { - $key = 'files.simpleDialog.enable' - $settingsDirectory = Split-Path -Parent $settingsFile - New-Item -ItemType Directory -Force -Path $settingsDirectory | Out-Null - - if (Test-Path -LiteralPath $settingsFile -PathType Leaf) { - $text = [IO.File]::ReadAllText($settingsFile) - } else { - $text = '' - } - - if ([string]::IsNullOrWhiteSpace($text)) { - [IO.File]::WriteAllText($settingsFile, "{`n `"$key`": true`n}`n", [Text.UTF8Encoding]::new($false)) - return - } - - # Match against a comment-masked copy so a commented-out occurrence such as - # `// "files.simpleDialog.enable": false` is not mistaken for the real - # setting. Offsets line up with the original, so the value is rewritten in - # place without disturbing comments. - $maskedText = Get-JsoncCodeMask $text - $keyPattern = [regex]::Escape($key) - $keyValueRegex = [regex]::new("(`"$keyPattern`"\s*:\s*)(true|false|null|`"[^`"`r`n]*`"|-?\d+(?:\.\d+)?)") - $keyMatch = $keyValueRegex.Match($maskedText) - if ($keyMatch.Success) { - $valueGroup = $keyMatch.Groups[2] - $updated = $text.Substring(0, $valueGroup.Index) + 'true' + $text.Substring($valueGroup.Index + $valueGroup.Length) - [IO.File]::WriteAllText($settingsFile, $updated, [Text.UTF8Encoding]::new($false)) - return - } - - $lastBrace = $maskedText.LastIndexOf('}') - if ($lastBrace -eq -1) { - throw "settings.json has no closing brace - refusing to clobber it: $settingsFile" - } - $firstBrace = $maskedText.IndexOf('{') - if ($firstBrace -eq -1 -or $firstBrace -ge $lastBrace) { - throw "settings.json has no opening brace - refusing to clobber it: $settingsFile" - } - - # Whether a leading comma is needed depends only on real content, so decide - # it from the masked copy too. - $between = $maskedText.Substring($firstBrace + 1, $lastBrace - $firstBrace - 1).Trim() - $separator = if ($between.Length -eq 0 -or $between.EndsWith(',')) { '' } else { ',' } - $insertion = "$separator`n `"$key`": true`n" - $updated = $text.Substring(0, $lastBrace) + $insertion + $text.Substring($lastBrace) - [IO.File]::WriteAllText($settingsFile, $updated, [Text.UTF8Encoding]::new($false)) -} - function Write-LogTail([string]$logFile) { if (Test-Path -LiteralPath $logFile) { Get-Content -LiteralPath $logFile -Tail 80 | ForEach-Object { [Console]::Error.WriteLine($_) } @@ -442,6 +346,13 @@ for ($index = 0; $index -lt $cliArgs.Count; $index++) { $agents = $true continue } + '--session-title' { + if ($index + 1 -ge $cliArgs.Count) { + Exit-Usage 'Missing value for --session-title.' + } + $sessionTitle = $cliArgs[++$index] + continue + } '--source-user-data-dir' { if ($index + 1 -ge $cliArgs.Count) { Exit-Usage 'Missing value for --source-user-data-dir.' @@ -489,6 +400,10 @@ for ($index = 0; $index -lt $cliArgs.Count; $index++) { } } +if ($agents -and -not [string]::IsNullOrWhiteSpace($sessionTitle)) { + Exit-Usage '--session-title is only supported for regular editor windows; window.title is read-only in the Agents window.' +} + try { $launchStopwatch = [Diagnostics.Stopwatch]::StartNew() if ([string]::IsNullOrWhiteSpace($repo)) { @@ -574,8 +489,16 @@ try { } $settingsFile = Join-Path $destinationUdd 'User\settings.json' - Ensure-SimpleDialogSetting $settingsFile + $sourceSettingsFile = Join-Path $sourceUserDataDir 'User\settings.json' + $settingsScript = Join-Path $PSScriptRoot 'updateSettings.ts' + & $node $settingsScript $settingsFile $sessionTitle $sourceSettingsFile + if ($LASTEXITCODE -ne 0) { + throw "Failed to update launch settings in $settingsFile" + } Write-LaunchError "[launch.ps1] ensured files.simpleDialog.enable=true in $settingsFile" + if (-not [string]::IsNullOrWhiteSpace($sessionTitle)) { + Write-LaunchError "[launch.ps1] set window.title for session: $sessionTitle" + } $profileReadyMs = $launchStopwatch.ElapsedMilliseconds $launchArgs = [System.Collections.Generic.List[string]]::new() diff --git a/.agents/skills/launch/scripts/launch.sh b/.agents/skills/launch/scripts/launch.sh index ba99b6b5d8fd07..0b25696c90c98e 100755 --- a/.agents/skills/launch/scripts/launch.sh +++ b/.agents/skills/launch/scripts/launch.sh @@ -12,7 +12,7 @@ # caller can pick them up programmatically. Logs go to stderr. # # Usage: -# launch.sh [--agents] [--source-user-data-dir ] [--repo ] +# launch.sh [--agents] [--session-title ] [--source-user-data-dir <path>] [--repo <vscode-repo-root>] # [--clone-extensions] [--full] [--skip-prelaunch] # [--disable-workspace-trust] [-- <extra code.sh args>] # @@ -43,10 +43,19 @@ CLONE_EXTENSIONS=0 FULL=0 SKIP_PRELAUNCH=0 DISABLE_WORKSPACE_TRUST=0 +SESSION_TITLE="" while [[ $# -gt 0 ]]; do case "$1" in --agents) AGENTS=1; shift ;; + --session-title) + if [[ $# -lt 2 ]]; then + echo "Missing value for --session-title." >&2 + exit 2 + fi + SESSION_TITLE="$2" + shift 2 + ;; --source-user-data-dir) SOURCE_UDD="$2"; shift 2 ;; --repo) REPO="$2"; shift 2 ;; --clone-extensions|--copy-extensions) CLONE_EXTENSIONS=1; shift ;; @@ -58,6 +67,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$AGENTS" == "1" && -n "$SESSION_TITLE" ]]; then + echo "--session-title is only supported for regular editor windows; window.title is read-only in the Agents window." >&2 + exit 2 +fi + monotonic_ms() { node -e 'process.stdout.write(String(process.hrtime.bigint() / 1_000_000n))' } @@ -166,70 +180,17 @@ fi # always applied because every launched instance under this skill is # a throwaway used for automation. SETTINGS_FILE="$DEST_UDD/User/settings.json" +SOURCE_SETTINGS_FILE="$SOURCE_UDD/User/settings.json" mkdir -p "$(dirname "$SETTINGS_FILE")" -# Data-preserving text-based merge: insert/update `files.simpleDialog.enable` -# without reparsing the whole file. Avoids dropping user comments and -# string values containing `//` (e.g. URLs). Fails loudly if the file -# exists but has no recognizable JSON object shape — never silently -# overwrites with `{}`. -if ! node - "$SETTINGS_FILE" <<'NODE' -const fs = require('fs'); -const f = process.argv[2]; -const KEY = 'files.simpleDialog.enable'; - -let text; -try { text = fs.readFileSync(f, 'utf8'); } -catch (e) { - if (e.code === 'ENOENT') text = ''; - else { console.error('[launch.sh] cannot read ' + f + ': ' + e.message); process.exit(1); } -} - -// Empty file → write a fresh object. -if (text.trim() === '') { - fs.writeFileSync(f, '{\n "' + KEY + '": true\n}\n'); - process.exit(0); -} - -// Key already present (with any value) → update its value to `true` -// via a targeted regex on the value slot only. -const keyValueRe = new RegExp('("' + KEY.replace(/\./g, '\\.') + '"\\s*:\\s*)(true|false|null|"[^"\\n]*"|-?\\d+(?:\\.\\d+)?)', 'g'); -if (keyValueRe.test(text)) { - const updated = text.replace(keyValueRe, '$1true'); - fs.writeFileSync(f, updated); - process.exit(0); -} - -// Otherwise: find the LAST `}` and insert the new key before it. -// We deliberately don't parse JSONC — this preserves comments and -// any other content the source profile had. -const lastBrace = text.lastIndexOf('}'); -if (lastBrace === -1) { - console.error('[launch.sh] settings.json has no closing brace — refusing to clobber it: ' + f); - process.exit(1); -} - -// Decide whether to add a leading comma. If the only thing between the -// first `{` and the last `}` is whitespace and comments, the object is -// empty for our purposes and no comma is needed. -const firstBrace = text.indexOf('{'); -if (firstBrace === -1 || firstBrace >= lastBrace) { - console.error('[launch.sh] settings.json has no opening brace — refusing to clobber it: ' + f); - process.exit(1); -} -const between = text.slice(firstBrace + 1, lastBrace) - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\/\/[^\n]*/g, '') - .trim(); -const separator = between.length === 0 || between.endsWith(',') ? '' : ','; -const insertion = separator + '\n "' + KEY + '": true\n'; - -fs.writeFileSync(f, text.slice(0, lastBrace) + insertion + text.slice(lastBrace)); -NODE -then - echo "[launch.sh] failed to ensure files.simpleDialog.enable=true in $SETTINGS_FILE — automation may need to fall back to per-key input" >&2 +SETTINGS_SCRIPT="$(cd "$(dirname "$0")" && pwd)/updateSettings.ts" +if ! node "$SETTINGS_SCRIPT" "$SETTINGS_FILE" "$SESSION_TITLE" "$SOURCE_SETTINGS_FILE"; then + echo "[launch.sh] failed to update launch settings in $SETTINGS_FILE" >&2 exit 1 fi echo "[launch.sh] ensured files.simpleDialog.enable=true in $SETTINGS_FILE" >&2 +if [[ -n "$SESSION_TITLE" ]]; then + echo "[launch.sh] set window.title for session: $SESSION_TITLE" >&2 +fi PROFILE_READY_MS=$(monotonic_ms) # Strip ELECTRON_RUN_AS_NODE, commonly inherited from VS Code's integrated diff --git a/.agents/skills/launch/scripts/updateSettings.ts b/.agents/skills/launch/scripts/updateSettings.ts new file mode 100644 index 00000000000000..00456c847ca2cd --- /dev/null +++ b/.agents/skills/launch/scripts/updateSettings.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'node:fs'; + +const settingsFile = process.argv[2]; +const sessionTitle = process.argv[3]?.replace(/\s+/g, ' ').trim().replaceAll('$', '\uFF04'); +const sourceSettingsFile = process.argv[4]; + +if (!settingsFile) { + throw new Error('Usage: updateSettings.ts <settings-file> [session-title] [source-settings-file]'); +} + +let settingsStat; +try { + settingsStat = fs.lstatSync(settingsFile); +} catch (error) { + if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } +} + +let text; +if (settingsStat) { + if (settingsStat.isSymbolicLink()) { + text = fs.readFileSync(sourceSettingsFile ?? settingsFile, 'utf8'); + fs.unlinkSync(settingsFile); + } else { + text = fs.readFileSync(settingsFile, 'utf8'); + } +} else { + text = ''; +} + +if (!text.trim()) { + text = '{}\n'; +} + +text = setJsoncProperty(text, 'files.simpleDialog.enable', true); +if (sessionTitle) { + text = setJsoncProperty( + text, + 'window.title', + `${sessionTitle}\${separator}\${rootName}\${separator}\${appName}` + ); +} + +fs.writeFileSync(settingsFile, text); + +function setJsoncProperty(text: string, key: string, value: boolean | string): string { + const maskedText = maskComments(text); + const properties = findRootProperties(maskedText, key); + const property = properties[properties.length - 1]; + const serializedValue = JSON.stringify(value); + + if (property) { + return text.slice(0, property.valueStart) + serializedValue + text.slice(property.valueEnd); + } + + const firstBrace = maskedText.indexOf('{'); + const lastBrace = maskedText.lastIndexOf('}'); + if (firstBrace === -1 || lastBrace === -1 || firstBrace >= lastBrace) { + throw new Error(`settings.json has no top-level object - refusing to clobber it: ${settingsFile}`); + } + + const lineEnding = text.includes('\r\n') ? '\r\n' : '\n'; + const contents = maskedText.slice(firstBrace + 1, lastBrace).trim(); + const separator = !contents || contents.endsWith(',') ? '' : ','; + const insertion = `${separator}${lineEnding} ${JSON.stringify(key)}: ${serializedValue}${lineEnding}`; + return text.slice(0, lastBrace) + insertion + text.slice(lastBrace); +} + +function findRootProperties(text: string, key: string): { valueStart: number; valueEnd: number }[] { + let depth = 0; + const properties: { valueStart: number; valueEnd: number }[] = []; + + for (let index = 0; index < text.length; index++) { + const current = text[index]; + if (current === '{' || current === '[') { + depth++; + continue; + } + if (current === '}' || current === ']') { + depth--; + continue; + } + if (current !== '"') { + continue; + } + + const stringEnd = findStringEnd(text, index); + if (depth === 1 && JSON.parse(text.slice(index, stringEnd)) === key) { + let valueStart = stringEnd; + while (/\s/.test(text[valueStart])) { + valueStart++; + } + if (text[valueStart] === ':') { + valueStart++; + while (/\s/.test(text[valueStart])) { + valueStart++; + } + const valueMatch = /^(?:"(?:\\.|[^"\\\r\n])*"|true|false|null|-?\d+(?:\.\d+)?)/.exec(text.slice(valueStart)); + if (!valueMatch) { + throw new Error(`Unsupported value for ${key} in ${settingsFile}`); + } + properties.push({ valueStart, valueEnd: valueStart + valueMatch[0].length }); + } + } + index = stringEnd - 1; + } + + return properties; +} + +function findStringEnd(text: string, start: number): number { + let escaped = false; + for (let index = start + 1; index < text.length; index++) { + if (escaped) { + escaped = false; + } else if (text[index] === '\\') { + escaped = true; + } else if (text[index] === '"') { + return index + 1; + } + } + throw new Error(`Unterminated string in ${settingsFile}`); +} + +function maskComments(text: string): string { + const characters = text.split(''); + let inString = false; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + + for (let index = 0; index < characters.length; index++) { + const current = characters[index]; + const next = characters[index + 1]; + + if (inLineComment) { + if (current === '\n') { + inLineComment = false; + } else if (current !== '\r') { + characters[index] = ' '; + } + continue; + } + + if (inBlockComment) { + if (current === '*' && next === '/') { + characters[index] = ' '; + characters[index + 1] = ' '; + index++; + inBlockComment = false; + } else if (current !== '\r' && current !== '\n') { + characters[index] = ' '; + } + continue; + } + + if (inString) { + if (escaped) { + escaped = false; + } else if (current === '\\') { + escaped = true; + } else if (current === '"') { + inString = false; + } + continue; + } + + if (current === '"') { + inString = true; + } else if (current === '/' && next === '/') { + characters[index] = ' '; + characters[index + 1] = ' '; + index++; + inLineComment = true; + } else if (current === '/' && next === '*') { + characters[index] = ' '; + characters[index + 1] = ' '; + index++; + inBlockComment = true; + } + } + + return characters.join(''); +} diff --git a/.github/skills/agent-host-e2e-tests/SKILL.md b/.github/skills/agent-host-e2e-tests/SKILL.md index b4b31db0f8eea8..8e221cc2e18af2 100644 --- a/.github/skills/agent-host-e2e-tests/SKILL.md +++ b/.github/skills/agent-host-e2e-tests/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-host-e2e-tests -description: Use when writing, recording, updating, or troubleshooting the agent host end-to-end tests under src/vs/platform/agentHost/test/node/e2e (black-box tests that drive the whole agent host over the AHP protocol, using a CapiReplayProxy record/replay system for Claude/Copilot/Codex). Covers adding a cross-provider test, re-recording fixtures after an SDK bump, gating non-deterministic or platform-specific tests, and diagnosing replay cache misses. +description: Use when writing, recording, updating, validating, or troubleshooting the agent host end-to-end tests under src/vs/platform/agentHost/test/node/e2e (black-box tests that drive the whole agent host over the AHP protocol, using a CapiReplayProxy record/replay system for Claude/Copilot/Codex). Covers adding a cross-provider test, re-recording fixtures after an SDK bump, cross-platform Azure validation, gating non-deterministic or platform-specific tests, and diagnosing replay cache misses. --- # Agent host end-to-end tests @@ -27,6 +27,8 @@ It documents the mental model, the fixture format, every config flag, and a symp 2. Keep the prompt minimal and deterministic (fewer model turns → smaller, more robust fixtures). 3. Record fixtures for every enabled provider (Workflow B). Host-only tests need no per-test recording: the shared empty fixture remains strict and fails on any model request. 4. **Review the diff** (Workflow B step 3), then run the test in plain replay mode to confirm it's green, then commit the test + fixtures together. +5. Run the full deterministic suite and coverage workflow described in the E2E README. +6. Open or update a draft PR, then complete the cross-platform Azure validation in Workflow D before considering the tests ready to merge. Provider-specific assertions go in that provider's `*.integrationTest.ts` after the `defineAgentHostE2ETests(config)` call. @@ -57,6 +59,19 @@ Real-time streaming, mid-turn aborts, and POSIX-specific local execution (shell Always add a comment explaining *why* the gate exists. Also add or update the corresponding entry in `e2e/KNOWN_ISSUES.md`. When the variant is enabled again, remove or update the entry in the same change. +## Workflow D — Cross-platform Azure validation + +New Agent Host E2E tests are not ready to merge after local replay alone. Push the branch, open or update a draft PR, then use the `azure-pipelines` skill to validate the real packaged Electron integration-test path. + +1. Queue VS Code pipeline definition `111` with `VSCODE_BUILD_TYPE=CI`; enable Windows, Linux, and macOS x64 while disabling publishing, release, Web, ARM, Alpine, and Snap artifacts. The `azure-pipelines` skill contains the canonical command. +2. Monitor jobs as they finish. Inspect a failed platform's Electron integration-test task immediately rather than waiting for unrelated stages to complete. +3. Treat the Agent Host E2E result as accepted only when the Electron integration tests succeed on Windows, Linux, and macOS. +4. Rerun an apparently unrelated or pre-existing failure in isolation before attributing it to the PR. +5. After a platform-specific fix, rerun at least that platform. Rerun all three platforms when the fix can affect shared behavior, provider fixtures, process lifecycle, or cross-platform paths. +6. Cancel obsolete builds after pushing a replacement commit. + +For additions involving timing, filesystem watching, process lifecycle, worktrees, reconnect/restart, or other known flake surfaces, require **two clean executions of every new test on each supported platform** before merge. A full three-platform build plus a targeted second build is sufficient when the second build runs the relevant tests on all affected platforms. + ## Verifying & troubleshooting - Run a single provider in replay: `./scripts/test-integration.sh --run <path>` (no env var). diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index ad04e126c4c1b7..015e6be556186d 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -182,9 +182,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "peer": true, "engines": { @@ -292,21 +292,21 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -316,20 +316,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -659,9 +645,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -806,9 +792,9 @@ } }, "node_modules/hono": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", - "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "peer": true, "engines": { @@ -861,9 +847,9 @@ "peer": true }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "peer": true, "engines": { diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 1962a937ec3ec7..a61a6525255bb5 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -61,7 +61,7 @@ "localization": { "description": { "key": "chat.agent.allowedNetworkDomains", - "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence." + "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "array", @@ -76,7 +76,7 @@ "localization": { "description": { "key": "chat.agent.deniedNetworkDomains", - "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`." + "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "array", @@ -106,7 +106,7 @@ "localization": { "description": { "key": "chat.agent.networkFilter", - "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled." + "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "boolean", diff --git a/cglicenses.json b/cglicenses.json index fab28867d222bd..b928f3808d9758 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -1227,5 +1227,33 @@ "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", "SOFTWARE" ] + }, + { + // Reason: @vscode/web-editors declares MIT in its package.json, but its npm + // tarball does not include a LICENSE file and ClearlyDefined does not cover it. + "name": "@vscode/web-editors", + "fullLicenseText": [ + "MIT License", + "", + "Copyright (c) Microsoft Corporation", + "", + "Permission is hereby granted, free of charge, to any person obtaining a copy", + "of this software and associated documentation files (the \"Software\"), to deal", + "in the Software without restriction, including without limitation the rights", + "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell", + "copies of the Software, and to permit persons to whom the Software is", + "furnished to do so, subject to the following conditions:", + "", + "The above copyright notice and this permission notice shall be included in all", + "copies or substantial portions of the Software.", + "", + "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", + "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", + "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", + "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", + "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", + "SOFTWARE" + ] } ] diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c83a04b76d13dc..529db684406e12 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5242,6 +5242,16 @@ "onExp" ] }, + "github.copilot.chat.searchSubagent.subagentSemanticSearchEnabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.searchSubagent.subagentSemanticSearchEnabled%", + "tags": [ + "advanced", + "experimental", + "onExp" + ] + }, "github.copilot.chat.agentDebugLog.fileLogging.enabled": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 8165b301befd75..fa3928b65a197a 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -506,6 +506,7 @@ "github.copilot.config.searchSubagent.model": "Model to use for the search subagent. When useAgenticProxy is enabled, defaults to 'vscode-agentic-search-router-a'. Otherwise defaults to the main agent model.", "github.copilot.config.searchSubagent.toolCallLimit": "Maximum number of tool calls the search subagent can make during exploration.", "github.copilot.config.searchSubagent.thoroughnessEnabled": "Enable the thoroughness parameter on the search subagent tool. When enabled, the caller can pass 'normal' or 'deep' to adjust the number of allowed tool-call turns (1× or 2× the base toolCallLimit respectively).", + "github.copilot.config.searchSubagent.subagentSemanticSearchEnabled": "Enable the semantic search tool for the search subagent.", "copilot.tools.executionSubagent.name": "Execution Subagent", "copilot.tools.executionSubagent.description": "Launch an execution-focused subagent that runs one or more terminal commands to accomplish a task. This subagent is powered by Google's Gemini-3-Flash model. It is designed to select an efficient summary of the terminal outputs to return to the main agent context.", "github.copilot.config.executionSubagent.enabled": "Enable the Execution Subagent tool in Copilot Chat. The Execution Subagent is designed to run terminal commands to accomplish an execution-based task. It is powered by Google's Gemini-3-Flash model.", diff --git a/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts index 6814f49d25bacf..4ab97e291479fb 100644 --- a/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts @@ -197,14 +197,15 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop<ISearchSubage const allTools = this.toolsService.getEnabledTools(this.options.request, endpoint); // Only include tools relevant for search operations. - // We include semantic_search (Codebase) and the basic search primitives. - // The Codebase tool checks for inSubAgent context to prevent nested tool calling loops. const allowedSearchTools = new Set([ - ToolName.Codebase, // Semantic search ToolName.FindFiles, ToolName.FindTextInFiles, ToolName.ReadFile ]); + const semanticSearchEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SubagentSemanticSearchEnabled, this._experimentationService); + if (semanticSearchEnabled) { + allowedSearchTools.add(ToolName.Codebase); + } return allTools.filter(tool => allowedSearchTools.has(tool.name as ToolName)); } diff --git a/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts b/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts index e4b25f9e03221e..473a67830139a7 100644 --- a/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts +++ b/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { CancellationToken, ChatRequest } from 'vscode'; +import type { CancellationToken, ChatRequest, LanguageModelToolInformation } from 'vscode'; import { IChatHookService } from '../../../../platform/chat/common/chatHookService'; import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; @@ -12,6 +12,7 @@ import { IChatModelInformation } from '../../../../platform/endpoint/common/endp import { ChatEndpoint } from '../../../../platform/endpoint/node/chatEndpoint'; import { SEARCH_AGENT_FAMILY, SearchAgentChatEndpoint } from '../../../../platform/endpoint/node/searchAgentChatEndpoint'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { mock } from '../../../../util/common/test/simpleMock'; import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { generateUuid } from '../../../../util/vs/base/common/uuid'; @@ -26,6 +27,19 @@ import { isContextOverflowBadRequest, } from '../../../prompt/node/searchSubagentToolCallingLoop'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { ToolName } from '../../../tools/common/toolNames'; +import { IToolsService } from '../../../tools/common/toolsService'; + +class TestToolsService extends mock<IToolsService>() { + override getEnabledTools(): LanguageModelToolInformation[] { + return [ + { name: ToolName.Codebase }, + { name: ToolName.FindFiles }, + { name: ToolName.FindTextInFiles }, + { name: ToolName.ReadFile }, + ] as LanguageModelToolInformation[]; + } +} class TestSearchSubagentToolCallingLoop extends SearchSubagentToolCallingLoop { public buildPromptCalls = 0; @@ -74,6 +88,10 @@ class TestSearchSubagentToolCallingLoop extends SearchSubagentToolCallingLoop { token, ); } + + public callGetAvailableTools(): Promise<LanguageModelToolInformation[]> { + return this.getAvailableTools(); + } } function createMockChatRequest(): ChatRequest { @@ -315,6 +333,63 @@ describe('SearchSubagentToolCallingLoop.shouldAutoRetry', () => { }); }); +describe('SearchSubagentToolCallingLoop.getAvailableTools', () => { + let disposables: DisposableStore; + let instantiationService: IInstantiationService; + let configurationService: IConfigurationService; + + beforeEach(() => { + disposables = new DisposableStore(); + const serviceCollection = disposables.add(createExtensionUnitTestingServices()); + serviceCollection.define(IChatHookService, new MockChatHookService()); + serviceCollection.define(IToolsService, new TestToolsService()); + const accessor = serviceCollection.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + configurationService = accessor.get(IConfigurationService); + }); + + afterEach(() => { + disposables.dispose(); + }); + + function createLoop(): TestSearchSubagentToolCallingLoop { + const options: ISearchSubagentToolCallingLoopOptions = { + conversation: createTestConversation(), + toolCallLimit: 10, + request: createMockChatRequest(), + location: ChatLocation.Panel, + promptText: 'find things', + }; + const loop = instantiationService.createInstance(TestSearchSubagentToolCallingLoop, options); + (loop as any).getEndpoint = async () => loop.fakeEndpoint; + disposables.add(loop); + return loop; + } + + it('includes semantic_search when enabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.SubagentSemanticSearchEnabled, true); + const tools = await createLoop().callGetAvailableTools(); + + expect(tools.map(tool => tool.name)).toEqual([ + ToolName.Codebase, + ToolName.FindFiles, + ToolName.FindTextInFiles, + ToolName.ReadFile, + ]); + }); + + it('excludes only semantic_search when disabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.SubagentSemanticSearchEnabled, false); + const tools = await createLoop().callGetAvailableTools(); + + expect(tools.map(tool => tool.name)).toEqual([ + ToolName.FindFiles, + ToolName.FindTextInFiles, + ToolName.ReadFile, + ]); + }); +}); + describe('SearchSubagentToolCallingLoop.getEndpoint (agentic proxy)', () => { let disposables: DisposableStore; let instantiationService: IInstantiationService; diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 20c28c726c5009..5176b2311e03c3 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -800,6 +800,8 @@ export namespace ConfigKey { export const SearchSubagentToolCallLimit = defineSetting<number>('chat.searchSubagent.toolCallLimit', ConfigType.ExperimentBased, 4); /** Enable the thoroughness parameter on the search subagent tool, which adjusts turn limits based on requested thoroughness */ export const SearchSubagentThoroughnessEnabled = defineSetting<boolean>('chat.searchSubagent.thoroughnessEnabled', ConfigType.ExperimentBased, false); + /** Enable semantic search for the search subagent */ + export const SubagentSemanticSearchEnabled = defineSetting<boolean>('chat.searchSubagent.subagentSemanticSearchEnabled', ConfigType.ExperimentBased, true); export const ExecutionSubagentToolEnabled = defineSetting<boolean>('chat.executionSubagent.enabled', ConfigType.ExperimentBased, false); export const SkillToolEnabled = defineSetting<boolean>('chat.skillTool.enabled', ConfigType.ExperimentBased, false); diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index d2287cac2786bf..501b09a0c60657 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { AsyncClipboardStrategy, CommentModeController, CommentsModel, CommentsView, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, commands, findNodeOffsetById, vscodeHostKeyboardProfile, vscodeLocalKeyboardProfile, type CodeBlockAstNode, type LinkPresentationKind } from '@vscode/markdown-editor'; -import { VirtualizedIframeEmbeddedEditorFactory, type IframeEmbeddedEditorProvider, type IframeEmbeddedEditorProviderSelector, type ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; +import { VirtualizedIframeEmbeddedEditorFactory, type IframeEmbeddedEditorHostTransport, type IframeEmbeddedEditorProvider, type IframeEmbeddedEditorProviderSelector, type ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; import { Disposable, autorun, observableValue } from '@vscode/observables'; import 'katex/dist/katex.min.css'; import '@vscode/markdown-editor/editor.css'; @@ -47,6 +47,68 @@ interface InitialState { readonly linkPresentationRules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[]; } +class CodeBlockEditorHostTransport implements IframeEmbeddedEditorHostTransport { + readonly #listeners = new Set<(message: unknown) => void>(); + readonly #pendingMessages: unknown[] = []; + readonly #postMessage: (message: unknown) => void; + readonly #onDispose: () => void; + #activated = false; + #disposed = false; + + readonly onMessage: IframeEmbeddedEditorHostTransport['onMessage'] = (listener: (message: unknown) => void) => { + if (this.#disposed) { + throw new Error('Code block editor host transport is disposed'); + } + this.#listeners.add(listener); + if (!this.#activated) { + this.#activated = true; + for (const message of this.#pendingMessages.splice(0)) { + listener(message); + } + } + return { dispose: () => this.#listeners.delete(listener) }; + }; + + constructor( + readonly runtimeId: string, + postMessage: (message: unknown) => void, + onDispose: () => void, + ) { + this.#postMessage = postMessage; + this.#onDispose = onDispose; + } + + sendMessage(message: unknown): void { + if (this.#disposed) { + throw new Error('Code block editor host transport is disposed'); + } + this.#postMessage(message); + } + + acceptMessage(message: unknown): void { + if (this.#disposed) { + return; + } + if (!this.#activated) { + this.#pendingMessages.push(message); + return; + } + for (const listener of this.#listeners) { + listener(message); + } + } + + dispose(): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#pendingMessages.length = 0; + this.#listeners.clear(); + this.#onDispose(); + } +} + class Editor extends Disposable { readonly model = new EditorModel(); isUpdatingFromExtension = false; @@ -54,7 +116,9 @@ class Editor extends Disposable { #mermaidCounter = 0; #codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] = []; #nextCodeBlockEditorRequestId = 1; + #nextCodeBlockEditorRuntimeId = 1; readonly #codeBlockEditorRequests = new Map<number, (descriptor: ResolvedIframeEmbeddedEditor | undefined) => void>(); + readonly #codeBlockEditorHostTransports = new Map<string, CodeBlockEditorHostTransport>(); #controller: EditorController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: VirtualizedIframeEmbeddedEditorFactory | undefined; @@ -126,6 +190,12 @@ class Editor extends Disposable { } break; } + case 'codeBlockEditorHostTransportMessage': { + if (typeof message.runtimeId === 'string') { + this.#codeBlockEditorHostTransports.get(message.runtimeId)?.acceptMessage(message.message); + } + break; + } case 'gutterMarkers': { const markers: GutterMarker[] = message.markers.map((marker: { start: number; endExclusive: number; type: GutterMarker['type'] }) => ({ range: OffsetRange.fromTo(marker.start, marker.endExclusive), @@ -168,6 +238,9 @@ class Editor extends Disposable { resolve(undefined); } this.#codeBlockEditorRequests.clear(); + for (const transport of Array.from(this.#codeBlockEditorHostTransports.values())) { + transport.dispose(); + } }, }); } @@ -179,6 +252,7 @@ class Editor extends Disposable { providers: this.#createIframeProviders(this.#codeBlockEditorProviders), scriptNonce, themeCss: () => `:root { ${document.documentElement.getAttribute('style') ?? ''} }`, + iframeBootstrapUrl: location.href, onAmbiguous: (language, providers) => this.#vscode.postMessage({ type: 'codeBlockEditorDiagnostic', message: `Ambiguous providers for ${language}: ${providers.map(provider => provider.id).join(', ')}`, @@ -393,12 +467,40 @@ class Editor extends Disposable { return definitions.map(definition => ({ id: definition.id, selector: definition.selector, + createHostTransport: runtimeKey => this.#createCodeBlockEditorHostTransport(definition.id, runtimeKey), resolve: definition.source.kind === 'static' ? async () => definition.source.kind === 'static' ? definition.source.descriptor : undefined : language => this.#resolveCodeBlockEditor(definition.id, language), })); } + #createCodeBlockEditorHostTransport(providerId: string, runtimeKey: string): CodeBlockEditorHostTransport { + const runtimeId = `${providerId}:${this.#nextCodeBlockEditorRuntimeId++}`; + const transport = new CodeBlockEditorHostTransport( + runtimeId, + message => this.#vscode.postMessage({ + type: 'codeBlockEditorHostTransportMessage', + runtimeId, + message, + }), + () => { + this.#codeBlockEditorHostTransports.delete(runtimeId); + this.#vscode.postMessage({ + type: 'disposeCodeBlockEditorHostTransport', + runtimeId, + }); + }, + ); + this.#codeBlockEditorHostTransports.set(runtimeId, transport); + this.#vscode.postMessage({ + type: 'createCodeBlockEditorHostTransport', + runtimeId, + providerId, + runtimeKey, + }); + return transport; + } + #resolveCodeBlockEditor(providerId: string, language: string): Promise<ResolvedIframeEmbeddedEditor | undefined> { const requestId = this.#nextCodeBlockEditorRequestId++; return new Promise(resolve => { @@ -515,6 +617,10 @@ function readResolvedCodeBlockEditor(value: unknown): ResolvedIframeEmbeddedEdit const descriptor = value as Record<string, unknown>; if ( typeof descriptor.html !== 'string' + || typeof descriptor.runtimeKey !== 'string' + || descriptor.runtimeKey.length === 0 + || (descriptor.resourceBaseUrl !== undefined && typeof descriptor.resourceBaseUrl !== 'string') + || (descriptor.hostTransport !== undefined && typeof descriptor.hostTransport !== 'boolean') || (descriptor.contentType !== 'text' && descriptor.contentType !== 'json') || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') || (descriptor.initialHeight !== undefined && (typeof descriptor.initialHeight !== 'number' || !Number.isFinite(descriptor.initialHeight) || descriptor.initialHeight <= 0)) diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 14795de9066d10..bca87cd3d18dd8 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-84", + "@vscode/markdown-editor": "^0.0.2-87", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", @@ -633,9 +633,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-84", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-84.tgz", - "integrity": "sha512-CD/FrfJTNnc3nVrdE6oVjHP+e12/4Ie84eaxSOSLkf7QhktkLhdrYFHz+tJ45jn9wGARMl41Bz0emnUSru80OA==", + "version": "0.0.2-87", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-87.tgz", + "integrity": "sha512-c1T5c2p2btf8NGVARkDN/qGezyZ25xCL/2KXqwsQeh9NmcH4bPZi2CtaeaLmQU2EyHS7WpdYKcIdwGMrJstLEg==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index ca96b6345b8467..998abf85286920 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1791,7 +1791,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-84", + "@vscode/markdown-editor": "^0.0.2-87", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", diff --git a/extensions/markdown-language-features/schemas/package.schema.json b/extensions/markdown-language-features/schemas/package.schema.json index 434e1b69d51b2e..dc8f89cb2bb577 100644 --- a/extensions/markdown-language-features/schemas/package.schema.json +++ b/extensions/markdown-language-features/schemas/package.schema.json @@ -69,7 +69,7 @@ }, "entrypoint": { "type": "string", - "description": "Extension-relative path to a self-contained HTML document" + "description": "Extension-relative path to the editor's HTML entrypoint. Relative scripts and assets are resolved from this file's directory" }, "contentType": { "type": "string", @@ -85,7 +85,7 @@ }, "markdown.codeBlockEditorProviders": { "type": "array", - "description": "Providers for self-contained HTML editors used by fenced code blocks in the Markdown editor", + "description": "Providers for HTML editors used by fenced code blocks in the Markdown editor", "items": { "type": "object", "additionalProperties": false, @@ -100,6 +100,12 @@ "minLength": 1, "description": "Identifier for this provider, unique within the extension" }, + "runtimeKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Opaque identity for the editor runtime template. Only compatible editors with equal runtime keys may reuse an iframe" + }, "selector": { "oneOf": [ { @@ -140,7 +146,7 @@ }, "entrypoint": { "type": "string", - "description": "Extension-relative path to a self-contained HTML document" + "description": "Extension-relative path to the editor's HTML entrypoint. Relative scripts and assets are resolved from this file's directory" } } }, diff --git a/extensions/markdown-language-features/src/markdownExtensions.ts b/extensions/markdown-language-features/src/markdownExtensions.ts index 0507ecd05cf234..d24b9108a042fe 100644 --- a/extensions/markdown-language-features/src/markdownExtensions.ts +++ b/extensions/markdown-language-features/src/markdownExtensions.ts @@ -48,6 +48,7 @@ export interface MarkdownCodeBlockEditorProvider { readonly providerId: string; readonly extension: vscode.Extension<unknown>; readonly extensionVersion: string; + readonly runtimeKey?: string; readonly selector: MarkdownCodeBlockEditorSelector; readonly source: MarkdownCodeBlockEditorSource; readonly contentType: 'text' | 'json'; @@ -100,6 +101,7 @@ export namespace MarkdownContributions { && x.providerId === y.providerId && x.extension.id === y.extension.id && x.extensionVersion === y.extensionVersion + && x.runtimeKey === y.runtimeKey && selectorEqual(x.selector, y.selector) && sourceEqual(x.source, y.source) && x.contentType === y.contentType @@ -179,6 +181,7 @@ export namespace MarkdownContributions { typeof provider.id !== 'string' || !selector || !source + || (provider.runtimeKey !== undefined && (typeof provider.runtimeKey !== 'string' || provider.runtimeKey.length === 0 || provider.runtimeKey.length > 256)) || (provider.contentType !== undefined && provider.contentType !== 'text' && provider.contentType !== 'json') || (provider.initialHeight !== undefined && !isPositiveNumber(provider.initialHeight)) ) { @@ -189,6 +192,7 @@ export namespace MarkdownContributions { providerId: provider.id, extension, extensionVersion: typeof extension.packageJSON?.version === 'string' ? extension.packageJSON.version : '', + runtimeKey: provider.runtimeKey as string | undefined, selector, source, contentType: provider.contentType ?? 'text', diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index ff74f091048194..989d3ea4a3f2c9 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -28,6 +28,9 @@ interface CodeBlockEditorProviderDefinition { interface ResolvedCodeBlockEditor { readonly cacheKey?: string; readonly html: string; + readonly runtimeKey: string; + readonly resourceBaseUrl?: string; + readonly hostTransport?: boolean; readonly contentType: 'text' | 'json'; readonly initialHeight?: number; readonly sandbox?: MarkdownCodeBlockEditorSandbox; @@ -37,6 +40,10 @@ export interface MarkdownCodeBlockEditorApiV1 { getProvider(providerId: string): MarkdownCodeBlockEditorProviderApi | undefined; } +export interface MarkdownCodeBlockEditorApiV2 { + getProvider(providerId: string): MarkdownCodeBlockEditorProviderApi | undefined; +} + interface MarkdownCodeBlockEditorProviderApi { resolve( request: { @@ -46,18 +53,30 @@ interface MarkdownCodeBlockEditorProviderApi { }, token: vscode.CancellationToken, ): vscode.ProviderResult<ProviderResolvedCodeBlockEditor>; + createHostTransport?( + transport: MarkdownCodeBlockEditorHostTransport, + token: vscode.CancellationToken, + ): vscode.ProviderResult<vscode.Disposable>; } interface ProviderResolvedCodeBlockEditor { readonly content: - | { readonly html: string; readonly uri?: undefined } + | { readonly html: string; readonly baseUri?: vscode.Uri; readonly uri?: undefined } | { readonly html?: undefined; readonly uri: vscode.Uri }; readonly contentType?: 'text' | 'json'; readonly cacheKey?: string; + readonly runtimeKey?: string; readonly initialHeight?: number; readonly sandbox?: MarkdownCodeBlockEditorSandbox; } +interface MarkdownCodeBlockEditorHostTransport { + readonly runtimeKey: string; + readonly onDidReceiveMessage: vscode.Event<unknown>; + readonly onDidDispose: vscode.Event<void>; + sendMessage(message: unknown): void; +} + /** * Authenticates messages sent from the extension host to one Markdown editor webview. */ @@ -76,6 +95,66 @@ class AuthenticatedWebview { } } +class CodeBlockEditorHostTransportState implements vscode.Disposable { + readonly #onDidReceiveMessage = new vscode.EventEmitter<unknown>(); + readonly #onDidDispose = new vscode.EventEmitter<void>(); + readonly #pendingMessages: unknown[] = []; + #providerDisposable: vscode.Disposable | undefined; + #ready = false; + #disposed = false; + + readonly transport: MarkdownCodeBlockEditorHostTransport; + + constructor(runtimeKey: string, sendMessage: (message: unknown) => void) { + this.transport = Object.freeze({ + runtimeKey, + onDidReceiveMessage: this.#onDidReceiveMessage.event, + onDidDispose: this.#onDidDispose.event, + sendMessage: (message: unknown) => { + if (this.#disposed) { + throw new Error('Code block editor host transport is disposed'); + } + sendMessage(message); + }, + }); + } + + acceptMessage(message: unknown): void { + if (this.#disposed) { + return; + } + if (!this.#ready) { + this.#pendingMessages.push(message); + return; + } + this.#onDidReceiveMessage.fire(message); + } + + setReady(providerDisposable: vscode.Disposable | undefined): void { + if (this.#disposed) { + providerDisposable?.dispose(); + return; + } + this.#providerDisposable = providerDisposable; + this.#ready = true; + for (const message of this.#pendingMessages.splice(0)) { + this.#onDidReceiveMessage.fire(message); + } + } + + dispose(): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#pendingMessages.length = 0; + this.#onDidDispose.fire(); + this.#providerDisposable?.dispose(); + this.#onDidReceiveMessage.dispose(); + this.#onDidDispose.dispose(); + } +} + /** * Experimental hybrid (WYSIWYG) Markdown editor backed by the * `@vscode/markdown-editor` component. The {@link vscode.TextDocument} remains @@ -167,16 +246,19 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } const webview = new AuthenticatedWebview(webviewPanel.webview); this.#webviewPanels.set(webviewPanel, webview); - const codeBlockEditorProviders = this.#loadCodeBlockEditorProviders(); + const codeBlockEditorProviders = this.#loadCodeBlockEditorProviders(webviewPanel.webview); this.#wireSingle(document, webviewPanel, originalDocument, codeBlockEditorProviders, webview); this.#configureWebview(document, webview); } #configureWebview(document: vscode.TextDocument, editorWebview: AuthenticatedWebview): void { const webview = editorWebview.webview; + const codeBlockEditorResourceRoots = vscode.workspace.isTrusted + ? this.#contributions.contributions.codeBlockEditorProviders.map(provider => provider.extension.extensionUri) + : []; webview.options = { enableScripts: true, - localResourceRoots: getMarkdownLocalResourceRoots(document.uri, [this.#mediaRoot], { + localResourceRoots: getMarkdownLocalResourceRoots(document.uri, [this.#mediaRoot, ...codeBlockEditorResourceRoots], { includeWorkspaceResources: vscode.workspace.isTrusted, }), }; @@ -196,6 +278,49 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT let codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] | undefined; let contributionUpdate = 0; const resolveCancellation = new vscode.CancellationTokenSource(); + const hostTransports = new Map<string, CodeBlockEditorHostTransportState>(); + const disposeHostTransport = (runtimeId: string, expected?: CodeBlockEditorHostTransportState): void => { + const transport = hostTransports.get(runtimeId); + if (transport && (!expected || transport === expected)) { + hostTransports.delete(runtimeId); + transport.dispose(); + } + }; + const disposeHostTransports = (): void => { + for (const runtimeId of Array.from(hostTransports.keys())) { + disposeHostTransport(runtimeId); + } + }; + const createHostTransport = ( + runtimeId: string, + providerId: string, + runtimeKey: string, + ): void => { + disposeHostTransport(runtimeId); + const contribution = this.#contributions.contributions.codeBlockEditorProviders.find(candidate => candidate.id === providerId); + if (contribution?.source.kind !== 'exportApi' || contribution.source.apiVersion < 2 || !vscode.workspace.isTrusted) { + return; + } + const state = new CodeBlockEditorHostTransportState( + runtimeKey, + message => editorWebview.postMessage({ + type: 'codeBlockEditorHostTransportMessage', + runtimeId, + message, + }), + ); + hostTransports.set(runtimeId, state); + void this.#initializeCodeBlockEditorHostTransport(contribution, state, resolveCancellation.token).then(initialized => { + if (!initialized) { + disposeHostTransport(runtimeId, state); + } + }, error => { + if (!resolveCancellation.token.isCancellationRequested) { + this.#logger.trace('Markdown code block editor', `Provider ${providerId} failed to initialize a host transport`, error); + } + disposeHostTransport(runtimeId, state); + }); + }; const richLinks = new MarkdownEditorRichLinkController( document, this.#linkOpener, @@ -237,7 +362,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT ? this.#contributions.contributions.codeBlockEditorProviders.find(candidate => candidate.id === message.providerId) : undefined; const descriptor = provider && typeof message.language === 'string' - ? await this.#resolveCodeBlockEditor(provider, document.uri, message.language) + ? await this.#resolveCodeBlockEditor(provider, document.uri, message.language, editorWebview.webview) : undefined; if (resolveCancellation.token.isCancellationRequested) { break; @@ -250,6 +375,31 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT break; } + case 'createCodeBlockEditorHostTransport': { + if ( + typeof message.runtimeId === 'string' + && typeof message.providerId === 'string' + && typeof message.runtimeKey === 'string' + ) { + createHostTransport(message.runtimeId, message.providerId, message.runtimeKey); + } + break; + } + + case 'codeBlockEditorHostTransportMessage': { + if (typeof message.runtimeId === 'string') { + hostTransports.get(message.runtimeId)?.acceptMessage(message.message); + } + break; + } + + case 'disposeCodeBlockEditorHostTransport': { + if (typeof message.runtimeId === 'string') { + disposeHostTransport(message.runtimeId); + } + break; + } + case 'codeBlockEditorDiagnostic': { if (typeof message.message === 'string') { this.#logger.trace('Markdown code block editor', message.message); @@ -339,7 +489,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const comments = this.#wireComments(document, editorWebview); const onDidGrantWorkspaceTrust = vscode.workspace.onDidGrantWorkspaceTrust(() => { webviewReady = false; + disposeHostTransports(); this.#configureWebview(document, editorWebview); + void refreshCodeBlockEditorProviders(true, true); }); const refreshCodeBlockEditorProviders = async (clearProviderApis: boolean, force: boolean): Promise<void> => { const update = ++contributionUpdate; @@ -349,7 +501,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT this.#resolvedCodeBlockEditors.clear(); this.#resolvedCodeBlockEditorResources.clear(); } - const updatedCodeBlockEditorProviders = await this.#loadCodeBlockEditorProviders(); + const updatedCodeBlockEditorProviders = await this.#loadCodeBlockEditorProviders(editorWebview.webview); if ( update !== contributionUpdate || (!force && codeBlockEditorProviders && codeBlockEditorDefinitionsEqual(codeBlockEditorProviders, updatedCodeBlockEditorProviders)) @@ -381,12 +533,14 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT if (event.affectsConfiguration('markdown.experimental.richLinks.enabled', document.uri)) { richLinks.updateTargets([]); webviewReady = false; + disposeHostTransports(); this.#configureWebview(document, editorWebview); } }); const onDidChangeLinkPresentationRules = vscode.window.onDidChangeLinkPresentationRules(() => { richLinks.updateTargets([]); webviewReady = false; + disposeHostTransports(); this.#configureWebview(document, editorWebview); }); @@ -394,6 +548,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT contributionUpdate++; resolveCancellation.cancel(); resolveCancellation.dispose(); + disposeHostTransports(); this.#webviewPanels.delete(webviewPanel); this.#focusedWebviewPanels.delete(webviewPanel); this.#updateEditorFocusContext(); @@ -429,7 +584,10 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT await vscode.commands.executeCommand('setContext', 'markdownEditorFocus', focused); } - async #loadCodeBlockEditorProviders(): Promise<readonly CodeBlockEditorProviderDefinition[]> { + async #loadCodeBlockEditorProviders(webview: vscode.Webview): Promise<readonly CodeBlockEditorProviderDefinition[]> { + if (!vscode.workspace.isTrusted) { + return []; + } const result: CodeBlockEditorProviderDefinition[] = []; for (const provider of this.#contributions.contributions.codeBlockEditorProviders) { if (provider.source.kind === 'exportApi') { @@ -453,6 +611,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT kind: 'static', descriptor: { html: new TextDecoder('utf-8', { fatal: true }).decode(bytes), + runtimeKey: provider.runtimeKey ?? `${provider.id}@${provider.extensionVersion}`, + resourceBaseUrl: getCodeBlockEditorResourceBaseUrl(webview, provider.source.resource), contentType: provider.contentType, initialHeight: provider.initialHeight, sandbox: provider.sandbox, @@ -470,6 +630,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT contribution: MarkdownCodeBlockEditorProvider, documentUri: vscode.Uri, language: string, + webview: vscode.Webview, ): Promise<ResolvedCodeBlockEditor | undefined> { if (contribution.source.kind !== 'exportApi' || !vscode.workspace.isTrusted) { return undefined; @@ -477,7 +638,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const requestCacheKey = `${contribution.id}\0${documentUri.toString()}\0${language}`; let cached = this.#resolvedCodeBlockEditors.get(requestCacheKey); if (!cached) { - cached = this.#doResolveCodeBlockEditor(contribution, documentUri, language); + cached = this.#doResolveCodeBlockEditor(contribution, documentUri, language, webview); this.#resolvedCodeBlockEditors.set(requestCacheKey, cached); cached.then(result => { if (!result) { @@ -498,6 +659,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT contribution: MarkdownCodeBlockEditorProvider, documentUri: vscode.Uri, language: string, + webview: vscode.Webview, ): Promise<ResolvedCodeBlockEditor | undefined> { const cancellation = new vscode.CancellationTokenSource(); let timedOut = false; @@ -526,7 +688,13 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT if (value.content.uri !== undefined) { this.#resolvedCodeBlockEditorResources.add(value.content.uri.toString()); } - return await this.#readResolvedCodeBlockEditor(contribution, value); + return await this.#readResolvedCodeBlockEditor( + contribution, + value, + language, + webview, + typeof provider.createHostTransport === 'function', + ); }; const result = await Promise.race([operation(), cancelled]); if (timedOut) { @@ -545,16 +713,19 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } async #getCodeBlockEditorProvider(contribution: MarkdownCodeBlockEditorProvider): Promise<MarkdownCodeBlockEditorProviderApi | undefined> { - if (contribution.source.kind !== 'exportApi' || !isSupportedMarkdownCodeBlockEditorApiVersion(contribution.source.apiVersion)) { + const source = contribution.source; + if (source.kind !== 'exportApi' || !isSupportedMarkdownCodeBlockEditorApiVersion(source.apiVersion)) { return undefined; } let cached = this.#providerApis.get(contribution.id); if (!cached) { cached = (async () => { const exports = await contribution.extension.activate(); - const api = getMarkdownCodeBlockEditorApiV1(exports); + const api = source.apiVersion === 1 + ? getMarkdownCodeBlockEditorApiV1(exports) + : getMarkdownCodeBlockEditorApiV2(exports); if (!api) { - this.#logger.trace('Markdown code block editor', `Extension ${contribution.extension.id} does not export markdownCodeBlockEditors.apiV1`); + this.#logger.trace('Markdown code block editor', `Extension ${contribution.extension.id} does not export markdownCodeBlockEditors.apiV${source.apiVersion}`); return undefined; } const provider = api.getProvider(contribution.providerId); @@ -572,14 +743,19 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT async #readResolvedCodeBlockEditor( contribution: MarkdownCodeBlockEditorProvider, value: ProviderResolvedCodeBlockEditor, + language: string, + webview: vscode.Webview, + hasHostTransport: boolean, ): Promise<ResolvedCodeBlockEditor | undefined> { if (!isProviderResolvedCodeBlockEditor(value)) { this.#logger.trace('Markdown code block editor', `Provider ${contribution.id} returned an invalid descriptor`); return undefined; } let html: string; + let baseUri: vscode.Uri | undefined; if (value.content.html !== undefined) { html = value.content.html; + baseUri = value.content.baseUri; } else { if (!isAllowedCodeBlockEditorResource(value.content.uri, contribution.extension.extensionUri)) { this.#logger.trace('Markdown code block editor', `Provider ${contribution.id} returned a resource outside its extension and the workspace`); @@ -587,16 +763,38 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } const bytes = await vscode.workspace.fs.readFile(value.content.uri); html = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + baseUri = value.content.uri; + } + if (baseUri && !isAllowedCodeBlockEditorResource(baseUri, contribution.extension.extensionUri)) { + this.#logger.trace('Markdown code block editor', `Provider ${contribution.id} returned a base URI outside its extension and the workspace`); + return undefined; } return { cacheKey: value.cacheKey, html, + runtimeKey: value.runtimeKey ?? contribution.runtimeKey ?? value.cacheKey ?? `${contribution.id}@${contribution.extensionVersion}:${language}`, + resourceBaseUrl: baseUri ? getCodeBlockEditorResourceBaseUrl(webview, baseUri, value.content.html === undefined) : undefined, + hostTransport: hasHostTransport && contribution.source.kind === 'exportApi' && contribution.source.apiVersion >= 2, contentType: value.contentType ?? contribution.contentType, initialHeight: value.initialHeight ?? contribution.initialHeight, sandbox: intersectSandbox(contribution.sandbox, value.sandbox), }; } + async #initializeCodeBlockEditorHostTransport( + contribution: MarkdownCodeBlockEditorProvider, + state: CodeBlockEditorHostTransportState, + token: vscode.CancellationToken, + ): Promise<boolean> { + const provider = await this.#getCodeBlockEditorProvider(contribution); + if (!provider?.createHostTransport || token.isCancellationRequested) { + return false; + } + const disposable = await provider.createHostTransport(state.transport, token); + state.setReady(disposable ?? undefined); + return !token.isCancellationRequested; + } + #clearCodeBlockEditorCaches(): void { this.#providerApis.clear(); this.#resolvedCodeBlockEditors.clear(); @@ -779,7 +977,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Security-Policy" - content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; font-src ${webview.cspSource}; img-src ${webview.cspSource} https: data:; media-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}'; frame-src 'self';" /> + content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; font-src ${webview.cspSource}; img-src ${webview.cspSource} https: data:; media-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' ${webview.cspSource}; worker-src ${webview.cspSource} blob:; connect-src ${webview.cspSource}; frame-src 'self';" /> <meta name="vscode-markdown-editor-script-nonce" content="${nonce}" /> <meta name="vscode-markdown-editor-message-secret" content="${messageSecret}" /> <meta id="vscode-markdown-editor-initial-state" content="${initialState}" /> @@ -812,6 +1010,9 @@ function codeBlockEditorDefinitionsEqual( function resolvedCodeBlockEditorsEqual(a: ResolvedCodeBlockEditor, b: ResolvedCodeBlockEditor): boolean { return a.cacheKey === b.cacheKey && a.html === b.html + && a.runtimeKey === b.runtimeKey + && a.resourceBaseUrl === b.resourceBaseUrl + && a.hostTransport === b.hostTransport && a.contentType === b.contentType && a.initialHeight === b.initialHeight && a.sandbox?.forms === b.sandbox?.forms @@ -821,6 +1022,17 @@ function resolvedCodeBlockEditorsEqual(a: ResolvedCodeBlockEditor, b: ResolvedCo } export function getMarkdownCodeBlockEditorApiV1(value: unknown): MarkdownCodeBlockEditorApiV1 | undefined { + return getMarkdownCodeBlockEditorApi(value, 1); +} + +export function getMarkdownCodeBlockEditorApiV2(value: unknown): MarkdownCodeBlockEditorApiV2 | undefined { + return getMarkdownCodeBlockEditorApi(value, 2); +} + +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 1): MarkdownCodeBlockEditorApiV1 | undefined; +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 2): MarkdownCodeBlockEditorApiV2 | undefined; +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 1 | 2): MarkdownCodeBlockEditorApiV1 | MarkdownCodeBlockEditorApiV2 | undefined; +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 1 | 2): MarkdownCodeBlockEditorApiV1 | MarkdownCodeBlockEditorApiV2 | undefined { if (!value || typeof value !== 'object') { return undefined; } @@ -828,15 +1040,15 @@ export function getMarkdownCodeBlockEditorApiV1(value: unknown): MarkdownCodeBlo if (!namespace || typeof namespace !== 'object') { return undefined; } - const api = (namespace as Record<string, unknown>).apiV1; - return isMarkdownCodeBlockEditorApiV1(api) ? api : undefined; + const api = (namespace as Record<string, unknown>)[`apiV${apiVersion}`]; + return isMarkdownCodeBlockEditorApi(api) ? api : undefined; } -export function isSupportedMarkdownCodeBlockEditorApiVersion(value: number): value is 1 { - return value === 1; +export function isSupportedMarkdownCodeBlockEditorApiVersion(value: number): value is 1 | 2 { + return value === 1 || value === 2; } -function isMarkdownCodeBlockEditorApiV1(value: unknown): value is MarkdownCodeBlockEditorApiV1 { +function isMarkdownCodeBlockEditorApi(value: unknown): value is MarkdownCodeBlockEditorApiV1 | MarkdownCodeBlockEditorApiV2 { return typeof value === 'object' && value !== null && typeof (value as Record<string, unknown>).getProvider === 'function'; @@ -856,6 +1068,7 @@ function isProviderResolvedCodeBlockEditor(value: unknown): value is ProviderRes if ( (descriptor.contentType !== undefined && descriptor.contentType !== 'text' && descriptor.contentType !== 'json') || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') + || (descriptor.runtimeKey !== undefined && (typeof descriptor.runtimeKey !== 'string' || descriptor.runtimeKey.length === 0 || descriptor.runtimeKey.length > 256)) || (descriptor.initialHeight !== undefined && (!Number.isFinite(descriptor.initialHeight) || (descriptor.initialHeight as number) <= 0)) || !isSandbox(descriptor.sandbox) || !descriptor.content @@ -864,7 +1077,7 @@ function isProviderResolvedCodeBlockEditor(value: unknown): value is ProviderRes return false; } const content = descriptor.content as Record<string, unknown>; - return (typeof content.html === 'string' && content.uri === undefined) + return (typeof content.html === 'string' && content.uri === undefined && (content.baseUri === undefined || content.baseUri instanceof vscode.Uri)) || (content.html === undefined && content.uri instanceof vscode.Uri); } @@ -896,6 +1109,7 @@ function isAllowedCodeBlockEditorResource(resource: vscode.Uri, extensionUri: vs if (vscode.workspace.getWorkspaceFolder(resource)) { return true; } + if (resource.scheme !== extensionUri.scheme || resource.authority !== extensionUri.authority) { return false; } @@ -906,6 +1120,13 @@ function isAllowedCodeBlockEditorResource(resource: vscode.Uri, extensionUri: vs return resourcePath === extensionPath || resourcePath.startsWith(extensionPrefix); } +function getCodeBlockEditorResourceBaseUrl(webview: vscode.Webview, resource: vscode.Uri, resourceIsEntrypoint = true): string { + const path = resourceIsEntrypoint + ? resource.path.slice(0, resource.path.lastIndexOf('/') + 1) + : resource.path.endsWith('/') ? resource.path : `${resource.path}/`; + return webview.asWebviewUri(resource.with({ path, query: '', fragment: '' })).toString(); +} + function getNonce(): string { let text = ''; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts index 672c608d41fd12..b7dce252bae913 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import 'mocha'; import * as vscode from 'vscode'; import { MarkdownContributions } from '../markdownExtensions'; -import { getMarkdownCodeBlockEditorApiV1, isSupportedMarkdownCodeBlockEditorApiVersion, lineRangesToGutterMarkers } from '../preview/markdownEditorProvider'; +import { getMarkdownCodeBlockEditorApiV1, getMarkdownCodeBlockEditorApiV2, isSupportedMarkdownCodeBlockEditorApiVersion, lineRangesToGutterMarkers } from '../preview/markdownEditorProvider'; import { encodeWebviewInitialState } from '../preview/webviewInitialState'; suite('Markdown editor diff', () => { @@ -79,13 +79,32 @@ suite('Markdown code block editor API versioning', () => { }), undefined); }); - test('only advertises API version 1', () => { - assert.strictEqual(isSupportedMarkdownCodeBlockEditorApiVersion(1), true); - assert.strictEqual(isSupportedMarkdownCodeBlockEditorApiVersion(2), false); + test('accepts the namespaced V2 extension API', () => { + const apiV2 = { getProvider: () => undefined }; + assert.strictEqual(getMarkdownCodeBlockEditorApiV2({ + markdownCodeBlockEditors: { apiV2 }, + }), apiV2); + assert.strictEqual(getMarkdownCodeBlockEditorApiV2({ + markdownCodeBlockEditors: { apiV1: apiV2 }, + }), undefined); + }); + + test('advertises API versions 1 and 2', () => { + assert.deepStrictEqual( + [0, 1, 2, 3].map(isSupportedMarkdownCodeBlockEditorApiVersion), + [false, true, true, false], + ); + }); + + test('reads the optional runtime key', () => { + assert.strictEqual(readCodeBlockEditorProviders( + { kind: 'exportApi', apiVersion: 2 }, + 'shared-runtime', + )[0]?.runtimeKey, 'shared-runtime'); }); }); -function readCodeBlockEditorProviders(source: unknown) { +function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) { const extension = { id: 'test.markdown-code-block-editor', extensionUri: vscode.Uri.file('/test/markdown-code-block-editor'), @@ -96,6 +115,7 @@ function readCodeBlockEditorProviders(source: unknown) { id: 'test', selector: { language: 'test' }, source, + runtimeKey, }], }, }, diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-demo.md b/extensions/markdown-language-features/test-workspace/checkbox-count-demo.md new file mode 100644 index 00000000000000..a51a51bf8a842c --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-demo.md @@ -0,0 +1,20 @@ + +```checkbox-count +This code block is replaced by the extension-provided editor. +``` + + +# Checkbox count transport demo + +Edit or toggle these tasks. The code block editor below is updated by the +workspace extension through the Markdown editor's host transport. + +- [ ] First unchecked task +- [x] Completed task +- [ ] Second unchecked task + + +## More tasks + +* [ ] Third unchecked task +* [ ] Another completed task diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/.gitignore b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/.gitignore new file mode 100644 index 00000000000000..a5ce37086b732c --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/.gitignore @@ -0,0 +1,2 @@ +editor/dist/ +dist/ diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/README.md b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/README.md new file mode 100644 index 00000000000000..f1eee7435aa7b0 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/README.md @@ -0,0 +1,20 @@ +# Markdown checkbox-count demo extension + +This workspace extension demonstrates a Markdown code block editor whose UI is +loaded from external HTML, CSS, and TypeScript modules. The iframe communicates +with this extension through `WebEditorClient.hostTransport`. + +From the VS Code repository: + +1. Build `@vscode/markdown-editor` and `@vscode/web-editors` in the adjacent + `vscode-packages` checkout. +2. Run `npm install` and `npm run build` in this folder, then run `npm install` + in `extensions/markdown-language-features`. +3. Start the **Markdown Code Block Editor Demo** launch configuration. +4. Open `checkbox-count-demo.md` with the Markdown editor. +5. Toggle or edit task-list checkboxes. The count rendered by the code block + editor updates through the extension host. + +The build bundles `@vscode/web-editors` and its transitive dependencies into +the iframe entrypoint. The formatter remains a separate generated chunk so the +demo also exercises relative dynamic imports. diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/formatCheckboxLabel.ts b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/formatCheckboxLabel.ts new file mode 100644 index 00000000000000..9fc41b7da5a813 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/formatCheckboxLabel.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function formatTaskProgressLabel(checked: number, total: number): string { + return `${checked}/${total} tasks done`; +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/index.html b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/index.html new file mode 100644 index 00000000000000..161cd73c2c6e9d --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/index.html @@ -0,0 +1,24 @@ +<!--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. +----------------------------------------------------------------------------------------------> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="stylesheet" href="./style.css"> + <title>Task Progress + + +
+
+ Loading task progress... +
+
+
+
+
+ + + diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/main.ts b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/main.ts new file mode 100644 index 00000000000000..622b89eb8a97e0 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/main.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WebEditorClient } from '../node_modules/@vscode/web-editors/dist/index.js'; + +interface TaskProgressMessage { + readonly type: 'taskProgress'; + readonly checked: number; + readonly total: number; +} + +const mainElement = getElement('main'); +const progressElement = getElement('#progress'); +const progressLabelElement = getElement('#progress-label'); +const client = await WebEditorClient.connect({ connection: 'windowParent' }); +let reportedHeight: number | undefined; +let progressMessageVersion = 0; + +const reportSize = () => { + const mainHeight = Math.ceil(mainElement.getBoundingClientRect().height); + if (mainHeight === reportedHeight) { + return; + } + reportedHeight = mainHeight; + console.log('[checkbox-count] reporting iframe size', { + height: mainHeight, + documentScrollHeight: document.documentElement.scrollHeight, + bodyScrollHeight: document.body.scrollHeight, + mainHeight: mainElement.getBoundingClientRect().height, + }); + client.reportSize(mainHeight); +}; +const resizeObserver = new ResizeObserver(reportSize); +resizeObserver.observe(mainElement); +requestAnimationFrame(reportSize); + +if (!client.hostTransport) { + progressLabelElement.textContent = 'Host transport unavailable'; +} else { + client.hostTransport.onMessage(async message => { + if (!isTaskProgressMessage(message)) { + return; + } + const messageVersion = ++progressMessageVersion; + const { formatTaskProgressLabel } = await import('./formatCheckboxLabel.js'); + if (messageVersion !== progressMessageVersion) { + return; + } + const progressMaximum = Math.max(message.total, 1); + progressElement.setAttribute('aria-valuemax', progressMaximum); + progressElement.setAttribute('aria-valuenow', message.checked); + progressElement.style.setProperty('--task-progress-ratio', message.checked / progressMaximum); + progressLabelElement.textContent = formatTaskProgressLabel(message.checked, message.total); + reportSize(); + }); + client.hostTransport.sendMessage({ type: 'ready' }); +} + +window.addEventListener('beforeunload', () => { + resizeObserver.disconnect(); + client.dispose(); +}, { once: true }); + +function getElement(selector: string): T { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Missing required element: ${selector}`); + } + return element; +} + +function isTaskProgressMessage(message: unknown): message is TaskProgressMessage { + return typeof message === 'object' + && message !== null + && 'type' in message + && message.type === 'taskProgress' + && 'checked' in message + && typeof message.checked === 'number' + && Number.isInteger(message.checked) + && 'total' in message + && typeof message.total === 'number' + && Number.isInteger(message.total) + && message.checked >= 0 + && message.total >= 0 + && message.checked <= message.total; +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/style.css b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/style.css new file mode 100644 index 00000000000000..3701c977467864 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/style.css @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +:root { + color-scheme: light dark; +} + +html, +body { + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); +} + +body { + margin: 0; +} + +main { + display: grid; + gap: var(--vscode-spacing-size80); + box-sizing: border-box; + padding: var(--vscode-spacing-size120); + border-left: var(--vscode-strokeThickness) solid var(--vscode-textLink-foreground); + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); + font-family: var(--vscode-font-family); +} + +.task-progress-summary { + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.task-progress { + --task-progress-ratio: 0; + + width: 100%; + height: var(--vscode-spacing-size80); + overflow: hidden; + border-radius: var(--vscode-cornerRadius-xSmall); + background: var(--vscode-editor-background); +} + +.task-progress-value { + width: 100%; + height: 100%; + transform: scaleX(var(--task-progress-ratio)); + transform-origin: left; + background: var(--vscode-progressBar-background); +} + +@media (prefers-reduced-motion: no-preference) { + .task-progress-value { + transition: transform 160ms ease-out; + } +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package-lock.json b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package-lock.json new file mode 100644 index 00000000000000..7408f83c6142ce --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package-lock.json @@ -0,0 +1,579 @@ +{ + "name": "markdown-checkbox-count-demo", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "markdown-checkbox-count-demo", + "version": "0.0.1", + "dependencies": { + "@vscode/web-editors": "^0.0.2-41" + }, + "devDependencies": { + "esbuild": "0.27.2" + }, + "engines": { + "vscode": "^1.109.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hpke/common": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz", + "integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@hpke/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz", + "integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.10.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@vscode/hubrpc": { + "version": "0.0.2-15", + "resolved": "https://registry.npmjs.org/@vscode/hubrpc/-/hubrpc-0.0.2-15.tgz", + "integrity": "sha512-eN520Qs/0UmI5l/nEWYLqQrYfx9JX7zZ116m13YTs/8lK55iq+DLeM6GpMWWWf8TE4+OMi8hDabkl/FYRp9XPQ==", + "license": "MIT", + "dependencies": { + "@hpke/core": "^1.9.0", + "ws": "^8.18.0" + }, + "peerDependencies": { + "zod": "^4.4.3" + } + }, + "node_modules/@vscode/web-editors": { + "version": "0.0.2-41", + "resolved": "https://registry.npmjs.org/@vscode/web-editors/-/web-editors-0.0.2-41.tgz", + "integrity": "sha512-HAaDy/gdiSk0Yp38lpZ1Je1Cd7NcWuq1W+aUxwRO/JlOijmsJuZXukAdlIL78hLb0eT+Awrr8VGseY42FUT8rA==", + "license": "MIT", + "dependencies": { + "@vscode/hubrpc": "next" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package.json b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package.json new file mode 100644 index 00000000000000..04fc29cf5cd265 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package.json @@ -0,0 +1,46 @@ +{ + "name": "markdown-checkbox-count-demo", + "displayName": "Markdown Checkbox Count Demo", + "description": "Demonstrates a Markdown code block editor communicating with its extension host.", + "version": "0.0.1", + "publisher": "vscode-samples", + "private": true, + "engines": { + "vscode": "^1.109.0" + }, + "main": "./dist/extension.js", + "scripts": { + "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js && esbuild editor/main.ts --bundle --format=esm --splitting --outdir=editor/dist --entry-names=[name] --chunk-names=chunks/[name]-[hash]" + }, + "activationEvents": [ + "onStartupFinished" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": false + } + }, + "contributes": { + "markdown.codeBlockEditorProviders": [ + { + "id": "checkboxCount", + "runtimeKey": "checkbox-count-v1", + "selector": { + "language": "checkbox-count" + }, + "source": { + "kind": "exportApi", + "apiVersion": 2 + }, + "contentType": "text", + "initialHeight": 84 + } + ] + }, + "dependencies": { + "@vscode/web-editors": "^0.0.2-41" + }, + "devDependencies": { + "esbuild": "0.27.2" + } +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/src/extension.ts b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/src/extension.ts new file mode 100644 index 00000000000000..159a9d87e78d27 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/src/extension.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +const providerId = 'checkboxCount'; +const runtimeUris = new Map(); +const runtimeKeys = new Map(); +let nextRuntimeId = 1; + +interface MarkdownCodeBlockEditorResolveRequest { + readonly documentUri: vscode.Uri; +} + +interface MarkdownCodeBlockEditorHostTransport { + readonly runtimeKey: string; + sendMessage(message: TaskProgressMessage): void; + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable; + onDidDispose(listener: () => void): vscode.Disposable; +} + +interface TaskProgressMessage { + readonly type: 'taskProgress'; + readonly checked: number; + readonly total: number; +} + +export function activate(context: vscode.ExtensionContext) { + const provider = { + async resolve(request: MarkdownCodeBlockEditorResolveRequest) { + const documentKey = request.documentUri.toString(); + let runtimeKey = runtimeKeys.get(documentKey); + if (!runtimeKey) { + runtimeKey = `checkbox-count:${nextRuntimeId++}`; + runtimeKeys.set(documentKey, runtimeKey); + runtimeUris.set(runtimeKey, request.documentUri); + } + + return { + content: { + uri: vscode.Uri.joinPath(context.extensionUri, 'editor', 'index.html'), + }, + runtimeKey, + contentType: 'text', + initialHeight: 84, + }; + }, + + async createHostTransport(transport: MarkdownCodeBlockEditorHostTransport, token: vscode.CancellationToken) { + const documentUri = runtimeUris.get(transport.runtimeKey); + if (!documentUri) { + throw new Error(`Unknown checkbox-count runtime: ${transport.runtimeKey}`); + } + + const document = await vscode.workspace.openTextDocument(documentUri); + if (token.isCancellationRequested) { + return; + } + + const update = () => { + const progress = getTaskProgress(document.getText()); + transport.sendMessage({ + type: 'taskProgress', + checked: progress.checked, + total: progress.total, + }); + }; + + const documentListener = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.toString() === document.uri.toString()) { + update(); + } + }); + const messageListener = transport.onDidReceiveMessage(message => { + if (isReadyMessage(message)) { + update(); + } + }); + const disposeListener = transport.onDidDispose(() => { + console.log(`Disposed checkbox-count runtime ${transport.runtimeKey}`); + }); + return vscode.Disposable.from(documentListener, messageListener, disposeListener); + }, + }; + + return { + markdownCodeBlockEditors: { + apiV2: { + getProvider(id) { + return id === providerId ? provider : undefined; + }, + }, + }, + }; +} + +function isReadyMessage(message: unknown): message is { readonly type: 'ready' } { + return typeof message === 'object' && message !== null && 'type' in message && message.type === 'ready'; +} + +function getTaskProgress(text: string) { + const tasks = Array.from(text.matchAll(/^\s*[-*+]\s+\[(?[ x])\]/gim)); + return { + checked: tasks.filter(task => task.groups?.state.toLowerCase() === 'x').length, + total: tasks.length, + }; +} diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts index cefcef5a2ed2bb..efedffd0a6b224 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts @@ -91,14 +91,25 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.match(output, /Page ID:/, `Expected output to contain "Page ID:", got: ${output}`); }); - (vscode.env.remoteName ? test.skip : test)('Agent storage is shared between API and tool pages and isolated from persistent storage', async function () { + (vscode.env.remoteName ? test.skip : test)('Agent storage is shared, filtered, and isolated from persistent storage', async function () { this.timeout(60_000); const token = `${Date.now()}-${Math.random()}`; let agentReceivedCookie: string | undefined; let globalReceivedCookie: string | undefined; let workspaceReceivedCookie: string | undefined; - const server = http.createServer((request, response) => { + let agentProbeReceived = false; + let workspaceProbeReceived = false; + const server = http.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, resolve); + }); + + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const port = address.port; + server.on('request', (request, response) => { if (request.url === '/set-global') { response.setHeader('Set-Cookie', `vscode-browser-global-smoke=${token}; Path=/; SameSite=Lax`); response.end('global-cookie-set'); @@ -107,13 +118,13 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { if (request.url === '/set-workspace') { response.setHeader('Set-Cookie', `vscode-browser-workspace-smoke=${token}; Path=/; SameSite=Lax`); - response.end('workspace-cookie-set'); + response.end(`workspace-cookie-set`); return; } if (request.url === '/set-agent') { response.setHeader('Set-Cookie', `vscode-browser-agent-smoke=${token}; Path=/; SameSite=Lax`); - response.end('agent-cookie-set'); + response.end(`agent-cookie-set`); return; } @@ -135,35 +146,44 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { return; } - response.end('unexpected-request'); - }); + if (request.url === '/agent-probe') { + agentProbeReceived = true; + response.end(); + return; + } - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); + if (request.url === '/workspace-probe') { + workspaceProbeReceived = true; + response.end(); + return; + } - const address = server.address(); - assert.ok(address && typeof address !== 'string'); + response.end('unexpected-request'); + }); const browserConfig = vscode.workspace.getConfiguration('workbench.browser'); + const agentConfig = vscode.workspace.getConfiguration('chat.agent'); try { + await agentConfig.update('allowedNetworkDomains', ['*'], vscode.ConfigurationTarget.Global); + await agentConfig.update('deniedNetworkDomains', ['localhost'], vscode.ConfigurationTarget.Global); + await agentConfig.update('networkFilter', true, vscode.ConfigurationTarget.Global); + await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); - const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-global`); + const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-global`); for (let i = 0; i < 100 && !globalSetTab.title.startsWith('global-cookie-set'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(globalSetTab.title.startsWith('global-cookie-set'), `Expected Global page to load, got title "${globalSetTab.title}"`); await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); - const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-workspace`); - for (let i = 0; i < 100 && !workspaceSetTab.title.startsWith('workspace-cookie-set'); i++) { + const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-workspace`); + for (let i = 0; i < 100 && (!workspaceSetTab.title.startsWith('workspace-cookie-set') || !workspaceProbeReceived); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(workspaceSetTab.title.startsWith('workspace-cookie-set'), `Expected Workspace page to load, got title "${workspaceSetTab.title}"`); await browserConfig.update('dataStorage', 'agent', vscode.ConfigurationTarget.Global); - const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-agent`); + const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-agent`); for (let i = 0; i < 100 && !agentSetTab.title.startsWith('agent-cookie-set'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); @@ -171,18 +191,18 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.ok(agentSetTab.title.startsWith('agent-cookie-set'), `Expected Agent page to load, got title "${agentSetTab.title}"`); const output = await invokeTool('open_browser_page', { - url: `http://127.0.0.1:${address.port}/check-agent`, + url: `http://127.0.0.1:${port}/check-agent`, forceNew: true, }); await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); - const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-global`); + const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/check-global`); for (let i = 0; i < 100 && !globalCheckTab.title.startsWith('global-cookie-checked'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); - const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-workspace`); + const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/check-workspace`); for (let i = 0; i < 100 && !workspaceCheckTab.title.startsWith('workspace-cookie-checked'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } @@ -192,26 +212,33 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { agentSharedCookie: agentReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, agentReceivedGlobalCookie: agentReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, agentReceivedWorkspaceCookie: agentReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, + agentBlockedRequest: !agentProbeReceived, globalLoaded: globalCheckTab.title.startsWith('global-cookie-checked'), globalSharedCookie: globalReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, globalReceivedAgentCookie: globalReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, workspaceLoaded: workspaceCheckTab.title.startsWith('workspace-cookie-checked'), workspaceSharedCookie: workspaceReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, workspaceReceivedAgentCookie: workspaceReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + workspaceAllowedRequest: workspaceProbeReceived, }, { opened: true, agentSharedCookie: true, agentReceivedGlobalCookie: false, agentReceivedWorkspaceCookie: false, + agentBlockedRequest: true, globalLoaded: true, globalSharedCookie: true, globalReceivedAgentCookie: false, workspaceLoaded: true, workspaceSharedCookie: true, workspaceReceivedAgentCookie: false, + workspaceAllowedRequest: true, }); } finally { await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global); + await agentConfig.update('networkFilter', undefined, vscode.ConfigurationTarget.Global); + await agentConfig.update('allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); + await agentConfig.update('deniedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } }); diff --git a/package-lock.json b/package-lock.json index 0a585ac9ad0c34..88da7f3b3ca9d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -33,7 +33,7 @@ "@vscode/native-watchdog": "^1.4.6", "@vscode/os-proxy-resolver": "^0.4.0", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -1155,9 +1155,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-2.tgz", + "integrity": "sha512-ntRvwGdZbZJjOvdV3LVBQ7z69W19DO4MWe/dh6pP8kYeumbzC4udVj7mCtoSwBOs0hwXirhXY+BZwqSJUp5FLQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1166,20 +1166,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-2", + "@github/copilot-darwin-x64": "1.0.83-2", + "@github/copilot-linux-arm64": "1.0.83-2", + "@github/copilot-linux-x64": "1.0.83-2", + "@github/copilot-linuxmusl-arm64": "1.0.83-2", + "@github/copilot-linuxmusl-x64": "1.0.83-2", + "@github/copilot-win32-arm64": "1.0.83-2", + "@github/copilot-win32-x64": "1.0.83-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-2.tgz", + "integrity": "sha512-RBjF/zTJe+gp9PsthYtIHF36+y3A3Zv3w/2FAa9nHF6ople1+lYoQ0OC1z6tWQSpBx3n/fSkNt0ebMmfhWlQyw==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-2.tgz", + "integrity": "sha512-GzaarluCiHUA4yrYxWIFEklwPLTYjxGvLyYjI0wW5IPseT7GLFjN7AkDGRU4Ny/mxTj1MbUEsJAWVtiNy5EUHg==", "cpu": [ "x64" ], @@ -1209,9 +1209,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-2.tgz", + "integrity": "sha512-iGD5Pc7vnzrAjqTK/BrI8MDtMqXyxUe7P6u8TiGt+4DzKe3v2myT6eFQ+z/JJOHNpiu6LUpTDzwS8WttZqqJpA==", "cpu": [ "arm64" ], @@ -1228,9 +1228,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-2.tgz", + "integrity": "sha512-jOQwz075vRWat+RJiMNtD+7vm4DQ83Dvw4iakpNkLfqWMPXlliVr/tqL+SffeSo87LnhOKobZOJOjELS27drdg==", "cpu": [ "x64" ], @@ -1247,9 +1247,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-2.tgz", + "integrity": "sha512-6/+ByEBEV/vDofrCymaKrEZ5/p+TVHQfgWu8ywvWEPALfxj/iHpAF3grM2bj7uED4C19LTSarHNCkCqdrAUzIQ==", "cpu": [ "arm64" ], @@ -1266,9 +1266,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-2.tgz", + "integrity": "sha512-8dbiPUHaDemDibLfKXDe5xublJxt6fOht4yYDk/ikmOGAVezBUwRVO27PchXQBwGjP2PVNAASkv2WB4Ubw72lA==", "cpu": [ "x64" ], @@ -1300,9 +1300,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-2.tgz", + "integrity": "sha512-ZE1iUXlJSnNIH+VoRG96emy2e7jMJm85/pcKCrgLvqhDAcQSqiqLVv3OJp2VX2IcDxToOpiaGccU8Ab9zHQqkQ==", "cpu": [ "arm64" ], @@ -1316,9 +1316,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-2.tgz", + "integrity": "sha512-qfturLon+1oaWqSaXCISL6BEf5K+ijJFl0m/OZwstWYSlIlaE2+iOz5LcRukoxm25OG1JZDHQFp1k2PY2A+LbA==", "cpu": [ "x64" ], @@ -4964,9 +4964,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.44.0.tgz", - "integrity": "sha512-1vv0uJrIGxS89C+0gPmgNuOcw+Pjw0h7y0U3/l7pfwuiDq2Ua3evUVAkDj86v/Mn+UuFn20MWA04YyZ4huJcOA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.45.0.tgz", + "integrity": "sha512-rSR81pniNECvd3Zr1sq0VqYL7H+02dT723sEexpoweWi9mMklwLoapHTkwK6nAtxhjXNagRkT7l2GWuripXD7Q==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 78a154e6af25d3..8ad14851ea7c90 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -123,7 +123,7 @@ "@vscode/native-watchdog": "^1.4.6", "@vscode/os-proxy-resolver": "^0.4.0", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/remote/package-lock.json b/remote/package-lock.json index a61dff0c49c323..a17926c6a37af7 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -19,7 +19,7 @@ "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -61,9 +61,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-2.tgz", + "integrity": "sha512-ntRvwGdZbZJjOvdV3LVBQ7z69W19DO4MWe/dh6pP8kYeumbzC4udVj7mCtoSwBOs0hwXirhXY+BZwqSJUp5FLQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -72,20 +72,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-2", + "@github/copilot-darwin-x64": "1.0.83-2", + "@github/copilot-linux-arm64": "1.0.83-2", + "@github/copilot-linux-x64": "1.0.83-2", + "@github/copilot-linuxmusl-arm64": "1.0.83-2", + "@github/copilot-linuxmusl-x64": "1.0.83-2", + "@github/copilot-win32-arm64": "1.0.83-2", + "@github/copilot-win32-x64": "1.0.83-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-2.tgz", + "integrity": "sha512-RBjF/zTJe+gp9PsthYtIHF36+y3A3Zv3w/2FAa9nHF6ople1+lYoQ0OC1z6tWQSpBx3n/fSkNt0ebMmfhWlQyw==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-2.tgz", + "integrity": "sha512-GzaarluCiHUA4yrYxWIFEklwPLTYjxGvLyYjI0wW5IPseT7GLFjN7AkDGRU4Ny/mxTj1MbUEsJAWVtiNy5EUHg==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-2.tgz", + "integrity": "sha512-iGD5Pc7vnzrAjqTK/BrI8MDtMqXyxUe7P6u8TiGt+4DzKe3v2myT6eFQ+z/JJOHNpiu6LUpTDzwS8WttZqqJpA==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-2.tgz", + "integrity": "sha512-jOQwz075vRWat+RJiMNtD+7vm4DQ83Dvw4iakpNkLfqWMPXlliVr/tqL+SffeSo87LnhOKobZOJOjELS27drdg==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-2.tgz", + "integrity": "sha512-6/+ByEBEV/vDofrCymaKrEZ5/p+TVHQfgWu8ywvWEPALfxj/iHpAF3grM2bj7uED4C19LTSarHNCkCqdrAUzIQ==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-2.tgz", + "integrity": "sha512-8dbiPUHaDemDibLfKXDe5xublJxt6fOht4yYDk/ikmOGAVezBUwRVO27PchXQBwGjP2PVNAASkv2WB4Ubw72lA==", "cpu": [ "x64" ], @@ -206,9 +206,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-2.tgz", + "integrity": "sha512-ZE1iUXlJSnNIH+VoRG96emy2e7jMJm85/pcKCrgLvqhDAcQSqiqLVv3OJp2VX2IcDxToOpiaGccU8Ab9zHQqkQ==", "cpu": [ "arm64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-2.tgz", + "integrity": "sha512-qfturLon+1oaWqSaXCISL6BEf5K+ijJFl0m/OZwstWYSlIlaE2+iOz5LcRukoxm25OG1JZDHQFp1k2PY2A+LbA==", "cpu": [ "x64" ], @@ -910,9 +910,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.44.0.tgz", - "integrity": "sha512-1vv0uJrIGxS89C+0gPmgNuOcw+Pjw0h7y0U3/l7pfwuiDq2Ua3evUVAkDj86v/Mn+UuFn20MWA04YyZ4huJcOA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.45.0.tgz", + "integrity": "sha512-rSR81pniNECvd3Zr1sq0VqYL7H+02dT723sEexpoweWi9mMklwLoapHTkwK6nAtxhjXNagRkT7l2GWuripXD7Q==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index af7d55252059c3..309557bc0b3c9c 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -14,7 +14,7 @@ "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index 44e683fe5dce4a..fb08a7df208f0a 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, globalTransaction, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -15,6 +16,7 @@ import { IContextKeyService, type IScopedContextKeyService } from '../../../../p import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { observableCodeEditor } from '../../observableCodeEditor.js'; @@ -24,6 +26,8 @@ import { ActionRunnerWithContext } from './utils.js'; import { IVirtualizedItemBindingContext, VirtualizedItemBinding, VirtualizedItemTemplate } from './virtualizedItemManager.js'; import { IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from './workbenchUIElementFactory.js'; +export const binaryFilePlaceholderContentHeight = 100; + export class DiffEditorItemTemplate extends VirtualizedItemTemplate { private readonly _viewModel; @@ -46,11 +50,13 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; this.editor = this._register(this._instantiationService.createInstance(DiffEditorWidget, this._elements.editor, { @@ -125,7 +138,28 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this.isModifedFocused.read(reader) || this.isOriginalFocused.read(reader)); + this.isBinaryFilePlaceholderFocused = observableValue(this, false); + const binaryFilePlaceholderFocus = this._register(trackFocus(this._elements.binaryFilePlaceholder)); + this._register(binaryFilePlaceholderFocus.onDidFocus(() => this.isBinaryFilePlaceholderFocused.set(true, undefined))); + this._register(binaryFilePlaceholderFocus.onDidBlur(() => this.isBinaryFilePlaceholderFocused.set(false, undefined))); + this.isFocused = derived(this, reader => + this.isModifedFocused.read(reader) + || this.isOriginalFocused.read(reader) + || this.isBinaryFilePlaceholderFocused.read(reader) + ); + this._elements.binaryFilePlaceholder.tabIndex = 0; + if (this._workbenchUIElementFactory.openDiffEditor) { + this._openBinaryDiffButton = this._register(new Button(this._elements.binaryFilePlaceholderActions, { ...defaultButtonStyles, secondary: true })); + this._openBinaryDiffButton.label = localize('openBinaryDiff', "Open Diff"); + this._register(this._openBinaryDiffButton.onDidClick(() => { + const item = this._viewModel.get(); + if (item?.originalUri && item.modifiedUri) { + this._workbenchUIElementFactory.openDiffEditor?.(item.originalUri, item.modifiedUri); + } + })); + } else { + this._openBinaryDiffButton = undefined; + } this._resourceLabel = this._workbenchUIElementFactory.createResourceLabel ? this._register(this._workbenchUIElementFactory.createResourceLabel(this._elements.primaryPath, MultiDiffEditorItemLabelKind.Primary)) : undefined; @@ -202,7 +236,13 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { const collapsed = this._collapsed.read(reader); - this._elements.editor.style.display = collapsed ? 'none' : 'block'; + const item = this._viewModel.read(reader); + const isBinary = item?.isBinary === true; + const canOpenDiff = !!(item?.originalUri && item.modifiedUri && this._openBinaryDiffButton); + this._elements.editor.style.display = collapsed || isBinary ? 'none' : 'block'; + this._elements.binaryFilePlaceholder.style.display = !collapsed && isBinary ? 'grid' : 'none'; + this._elements.binaryFilePlaceholder.tabIndex = canOpenDiff ? -1 : 0; + this._elements.binaryFilePlaceholderActions.style.display = canOpenDiff ? '' : 'none'; if (this._workbenchUIElementFactory.headerClickToCollapse) { this._elements.header.setAttribute('aria-expanded', String(!collapsed)); } @@ -224,7 +264,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { + if (item.isBinary) { + return; + } const viewModel = item.diffEditorViewModel; if (!viewModel.isDiffUpToDate.read(reader)) { return; @@ -470,6 +515,15 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; } +/** + * A resource participating on one side of a document diff. + */ +export class DiffItemSource { + constructor( + public readonly uri: URI, + public readonly textModel: ITextModel | undefined, + ) { } +} + export interface IDocumentDiffItem { /** * undefined if the file was created. */ - readonly original: ITextModel | undefined; + readonly original: DiffItemSource | undefined; /** * undefined if the file was deleted. */ - readonly modified: ITextModel | undefined; + readonly modified: DiffItemSource | undefined; readonly options?: IDiffEditorOptions; readonly onOptionsDidChange?: Event; readonly contextKeys?: Record; diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 06827aa7300199..7eccee465388c4 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -135,6 +135,11 @@ export class DocumentDiffItemViewModel extends Disposable { public get originalUri(): URI | undefined { return this.documentDiffItem.original?.uri; } public get modifiedUri(): URI | undefined { return this.documentDiffItem.modified?.uri; } + public get isBinary(): boolean { + const { original, modified } = this.documentDiffItem; + return (original !== undefined && original.textModel === undefined) + || (modified !== undefined && modified.textModel === undefined); + } public readonly isActive: IObservable = derived(this, reader => this._editorViewModel.activeDiffItem.read(reader) === this); public readonly isFirst: IObservable = derived(this, reader => this._editorViewModel.items.read(reader)[0] === this); @@ -188,8 +193,8 @@ export class DocumentDiffItemViewModel extends Disposable { } const diffEditorViewModelStore = new DisposableStore(); - const originalTextModel = this.documentDiffItem.original ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); - const modifiedTextModel = this.documentDiffItem.modified ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); + const originalTextModel = this.documentDiffItem.original?.textModel ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); + const modifiedTextModel = this.documentDiffItem.modified?.textModel ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); diffEditorViewModelStore.add(this._documentDiffItemRef.createNewRef(this)); this.diffEditorViewModelRef = this._register(RefCounted.createWithDisposable( diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index befafc3fcf06d4..08a7507a3f5047 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -127,6 +127,10 @@ export class MultiDiffEditorWidget extends Disposable { public readonly onDidChangeActiveControl = Event.fromObservableLight(this._activeControl); + public focus(): boolean { + return this._widgetImpl.get().focus(); + } + public getViewState(): IMultiDiffEditorViewState { return this._widgetImpl.get().getViewState(); } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index cdf7d096bc0b60..89e7b9cad70b1f 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -23,7 +23,7 @@ import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { CompressedVirtualizedScrollView, ICompressedVirtualizedScrollItem, ICompressedVirtualizedScrollItemContext } from './compressedVirtualizedScrollView.js'; import { ICompressedVirtualizedScrollLayout } from './compressedVirtualizedScrollLayout.js'; -import { DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; +import { binaryFilePlaceholderContentHeight, DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem } from './model.js'; import { formatDiffItemKey, formatUri, ILoggedDiffItem, MultiDiffEditorLogger } from './multiDiffEditorLogging.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; @@ -100,9 +100,18 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const manager = this._register(new VirtualizedItemManager(sourceItems, context, { getId: item => item, getTemplateId: () => 'diffEditor', - getUnboundSize: item => derived(item, reader => item.collapsed.read(reader) - ? this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40 - : item.lastTemplateData.read(reader).expandedContentHeight), + getUnboundSize: item => derived(item, reader => { + const headerHeight = this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40; + if (item.collapsed.read(reader)) { + return headerHeight; + } + if (item.isBinary) { + return headerHeight + + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0) + + binaryFilePlaceholderContentHeight; + } + return item.lastTemplateData.read(reader).expandedContentHeight; + }), createTemplate: () => this._instantiationService.createInstance( DiffEditorItemTemplate, context.contentDomNode, @@ -516,30 +525,40 @@ export class MultiDiffEditorWidgetImpl extends Disposable { viewModel.activeDiffItem.setCache(target, undefined); if (!this._preserveFocusOnLoad) { - this._viewItemsInfo.get().getItem(target).template.get()?.editor.focus(); + this._viewItemsInfo.get().getItem(target).binding.get()?.focus(); } return true; } public findDocumentDiffItem(resource: URI): IDocumentDiffItem | undefined { const item = this._viewItems.get().find(v => - v.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString() - || v.viewModel.diffEditorViewModel.model.original.uri.toString() === resource.toString() + v.viewModel.modifiedUri?.toString() === resource.toString() + || v.viewModel.originalUri?.toString() === resource.toString() ); return item?.viewModel.documentDiffItem; } + public focus(): boolean { + const activeDiffItem = this._viewModel.get()?.activeDiffItem.get(); + if (!activeDiffItem) { + return false; + } + const binding = this._viewItemsInfo.get().getItem(activeDiffItem).binding.get(); + binding?.focus(); + return binding !== undefined; + } + public tryGetCodeEditor(resource: URI): { diffEditor: IDiffEditor; editor: ICodeEditor } | undefined { const item = this._viewItems.get().find(v => - v.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString() - || v.viewModel.diffEditorViewModel.model.original.uri.toString() === resource.toString() + v.viewModel.modifiedUri?.toString() === resource.toString() + || v.viewModel.originalUri?.toString() === resource.toString() ); const editor = item?.template.get()?.editor; - if (!editor) { + if (!editor || item.viewModel.isBinary) { return undefined; } - if (item.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString()) { + if (item.viewModel.modifiedUri?.toString() === resource.toString()) { return { diffEditor: editor, editor: editor.getModifiedEditor() }; } else { return { diffEditor: editor, editor: editor.getOriginalEditor() }; @@ -617,7 +636,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } } if (focusEditor) { - editor?.focus(); + item.binding.get()?.focus(); } } @@ -786,7 +805,7 @@ class VirtualizedViewItem extends Disposable implements ILoggedDiffItem, ICompre } public override toString(): string { - return `VirtualViewItem(${this.viewModel.documentDiffItem.modified?.uri.toString()})`; + return `VirtualViewItem(${this.viewModel.modifiedUri?.toString() ?? this.viewModel.originalUri?.toString()})`; } public getKey(): string { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/style.css b/src/vs/editor/browser/widget/multiDiffEditor/style.css index 595eba2d6407b4..138aa773b0b21b 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/style.css +++ b/src/vs/editor/browser/widget/multiDiffEditor/style.css @@ -154,5 +154,24 @@ .editorContainer { flex: 1; } + + .binary-file-placeholder { + display: none; + flex: 1; + place-items: center; + color: var(--vscode-descriptionForeground); + + &:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + .binary-file-placeholder-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--vscode-spacing-size80); + } + } } } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index f853df6d75ad9f..a5f61ea899d1c7 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -46,6 +46,9 @@ export interface IWorkbenchUIElementFactory { /** Handles a middle-click on an entry header. Returns whether the event was handled. */ handleHeaderMiddleClick?(resource: URI): boolean; + /** Opens an entry in a standalone diff editor. */ + openDiffEditor?(original: URI, modified: URI): void; + /** * Optional override for how individual actions render in the per-file header * toolbar (`MenuId.MultiDiffEditorFileToolbar`). Return `undefined` to fall diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index 7150e1bc0e3cef..e988b9cb967f57 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import sinon from 'sinon'; import { Dimension } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { Event, ValueWithChangeEvent } from '../../../../base/common/event.js'; import { autorun, waitForState } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -21,7 +22,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../platform/st import { IDiffProviderFactoryService } from '../../../browser/widget/diffEditor/diffProviderFactoryService.js'; import { DiffEditorWidget } from '../../../browser/widget/diffEditor/diffEditorWidget.js'; import { RefCounted } from '../../../browser/widget/diffEditor/utils.js'; -import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; +import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; @@ -82,6 +83,97 @@ suite('MultiDiffEditorWidget', () => { } }); + test('renders binary files as a placeholder', async () => { + const services = new ServiceCollection(); + services.set(IAccessibilitySignalService, new class extends mock() { }()); + services.set(IActionViewItemService, new NullActionViewItemService()); + services.set(IEditorProgressService, new class extends mock() { }()); + services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); + services.set(IStorageService, disposables.add(new InMemoryStorageService())); + services.set(IMenuService, new class extends mock() { + override createMenu(): IMenu { + return new class extends mock() { + override readonly onDidChange = Event.None; + override getActions() { return []; } + override dispose(): void { } + }(); + } + }()); + const instantiationService = createCodeEditorServices(disposables, services); + const originalUri = URI.parse('inmemory://original/image.png'); + const modifiedUri = URI.parse('inmemory://modified/image.png'); + const documentItem = RefCounted.createOfNonDisposable({ + original: new DiffItemSource(originalUri, undefined), + modified: new DiffItemSource(modifiedUri, undefined), + }, { dispose() { } }); + const model: IMultiDiffEditorModel = { + documents: ValueWithChangeEvent.const([documentItem]), + }; + let openedDiff: { original: URI; modified: URI } | undefined; + const container = document.createElement('div'); + const widget = instantiationService.createInstance( + MultiDiffEditorWidget, + container, + { + openDiffEditor: (original, modified) => openedDiff = { original, modified }, + } satisfies IWorkbenchUIElementFactory, + undefined, + ); + widget.layout(new Dimension(800, 600)); + const viewModel = widget.createViewModel(model); + await waitForState(viewModel.items, items => items.length === 1); + widget.setViewModel(viewModel); + widget.reveal({ original: originalUri, modified: modifiedUri }, { highlight: false }); + await waitForState(widget.getLayoutDebugState(), state => state.items[0]?.hasTemplate === true); + + try { + const placeholder = widget.getRootElement().querySelector('.binary-file-placeholder'); + const editor = widget.getRootElement().querySelector('.editorContainer'); + const openDiffButton = placeholder?.querySelector('.monaco-button'); + const focusSpy = sinon.spy(Button.prototype, 'focus'); + const canFocusActiveItem = widget.focus(); + openDiffButton?.click(); + assert.deepStrictEqual({ + text: placeholder?.textContent, + display: placeholder?.style.display, + tabIndex: placeholder?.tabIndex, + role: placeholder?.getAttribute('role'), + ariaLabel: placeholder?.getAttribute('aria-label'), + openDiffButtonText: openDiffButton?.textContent, + openDiffButtonSecondary: openDiffButton?.classList.contains('secondary'), + openDiffButtonFocused: focusSpy.calledOnce, + openedOriginalUri: openedDiff?.original.toString(), + openedModifiedUri: openedDiff?.modified.toString(), + editorDisplay: editor?.style.display, + itemHeight: widget.getLayoutDebugState().get().items[0].verticalState.contentHeight, + canFocusActiveItem, + findsDocumentItem: widget.findDocumentDiffItem(modifiedUri) === documentItem.object, + hasCodeEditorForBinaryResource: widget.tryGetCodeEditor(modifiedUri) !== undefined, + }, { + text: 'Binary file changedOpen Diff', + display: 'grid', + tabIndex: -1, + role: 'group', + ariaLabel: 'Binary file changed', + openDiffButtonText: 'Open Diff', + openDiffButtonSecondary: true, + openDiffButtonFocused: true, + openedOriginalUri: originalUri.toString(), + openedModifiedUri: modifiedUri.toString(), + editorDisplay: 'none', + itemHeight: 140, + canFocusActiveItem: true, + findsDocumentItem: true, + hasCodeEditorForBinaryResource: false, + }); + } finally { + widget.setViewModel(undefined); + viewModel.dispose(); + widget.dispose(); + documentItem.dispose(); + } + }); + test('applies document and responsive layout options before attaching the diff model', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); @@ -105,8 +197,8 @@ suite('MultiDiffEditorWidget', () => { const original = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, originalUri)); const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 2;', undefined, undefined, modifiedUri)); const documentItem = RefCounted.createOfNonDisposable({ - original, - modified, + original: new DiffItemSource(originalUri, original), + modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } }); const model: IMultiDiffEditorModel = { @@ -181,7 +273,7 @@ suite('MultiDiffEditorWidget', () => { const originalContent = Array.from({ length: 64 }, (_, index) => `line ${index}`).join('\n'); const original = disposables.add(instantiateTextModel(instantiationService, originalContent, undefined, undefined, originalUri)); const documentItem = RefCounted.createOfNonDisposable({ - original, + original: new DiffItemSource(originalUri, original), modified: undefined, options: { accessibilitySupport: 'off' }, }, { dispose() { } }); @@ -259,8 +351,8 @@ suite('MultiDiffEditorWidget', () => { const original = disposables.add(instantiateTextModel(instantiationService, '', undefined, undefined, originalUri)); const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, modifiedUri)); documentItems.push(RefCounted.createOfNonDisposable({ - original, - modified, + original: new DiffItemSource(originalUri, original), + modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } })); originalUris.push(originalUri); diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index ade98d06b9c829..3e056b1719a950 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -24,7 +24,7 @@ import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agen import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, type IAgentHostResourceUriMapper, toAgentHostUri } from '../common/agentHostUri.js'; import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; -import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; +import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; @@ -70,8 +70,8 @@ const PING_INTERVAL_MS = 5_000; /** * Total inbound silence (ping interval + this) before a non-local connection * is declared dead and force-closed so the renderer's reconnect logic kicks - * in. Reset on every received message; the only way to reach this is for the - * ping to itself go unanswered. + * in. Reset on every received message. After a deferred close resumes, this + * is also the full budget granted before the next liveness check. * * Matches {@link ProtocolConstants.TimeoutTime} from the regular remote * extension host stack. @@ -271,6 +271,20 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _authentication = new Map(); private _nextRequestId = 1; + /** + * Reverse requests awaiting a response, scoped to their incoming transport. + * An active count proves the host is waiting for client work rather than + * being silently dead. + */ + private readonly _pendingReverseRequests = new WeakMap(); + + /** + * Whether a liveness close has been deferred while the client was unable to + * receive traffic from the host. + */ + private _livenessDeferred = false; + private _livenessDeferredSince: number | undefined; + /** * Timestamp of the most recent message of any kind received from the * server. Used only for diagnostic logging when the close timer fires. @@ -284,9 +298,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * silence and sends an application-level `ping` so we have something * to time out on. {@link _closeTimer} fires after another * {@link LIVENESS_TIMEOUT_MS} of continued silence and force-closes - * the transport so the renderer's reconnect logic kicks in. Both are - * reset on every received message, so busy connections generate no - * ping traffic at all. + * the transport so the renderer's reconnect logic kicks in. If the host + * has an unanswered reverse request or the local event loop has high load, + * both timers re-arm instead. Once the deferral clears, it grants a full + * liveness window before closing. + * Both are reset on every received message, so busy connections generate + * no ping traffic at all. * * Detects silently-dead transports (e.g. SSH/tunnel after laptop * sleep + network change) that don't produce a socket close event of @@ -1732,12 +1749,34 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect // onto a new transport with a stale id — stray response at best, id // collision with a new server-issued reverse RPC at worst. const transport = this._transport; + this._pendingReverseRequests.set(transport, (this._pendingReverseRequests.get(transport) ?? 0) + 1); + const sendResponse = (response: JsonRpcResponse) => { + try { + transport.send(response); + } finally { + const pending = this._pendingReverseRequests.get(transport); + if (pending === 1) { + this._pendingReverseRequests.delete(transport); + // The peer now waits on bytes that still drain plus its own + // post-response work, and a close timer armed before this + // request arrived could fire moments from now. Grant a full + // window from this point instead. Guarded on the transport + // still being current so a late response cannot extend the + // life of timers that belong to a replacement transport. + if (transport === this._transport) { + this._resetLivenessTimers(); + } + } else if (pending !== undefined) { + this._pendingReverseRequests.set(transport, pending - 1); + } + } + }; const sendResult = (result: unknown) => { - transport.send({ jsonrpc: '2.0', id, result }); + sendResponse({ jsonrpc: '2.0', id, result }); }; const sendError = (err: unknown) => { if (err instanceof AgentHostResourcePermissionError) { - transport.send({ + sendResponse({ jsonrpc: '2.0', id, error: { @@ -1755,7 +1794,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect case FileSystemProviderErrorCode.NoPermissions: code = AhpErrorCodes.PermissionDenied; break; case FileSystemProviderErrorCode.FileExists: code = AhpErrorCodes.AlreadyExists; break; } - transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); + sendResponse({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); }; const p = (params ?? {}) as Record; @@ -2024,6 +2063,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * the transport so the renderer's reconnect logic kicks in. Catches * silently-dead transports (e.g. SSH/tunnel after laptop sleep + * network change) that don't emit a socket close event of their own. + * It defers while the host awaits a reverse-request response or the + * local event loop has high load. Once either deferral clears, it grants + * a fresh full liveness window. * * After laptop sleep + wake the JS event loop is paused, so a timer * armed before sleep fires immediately after wake. That's fine — @@ -2033,6 +2075,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * No-op while {@link _state.kind} is {@link AgentHostClientState.Incompatible}, * {@link AgentHostClientState.Reconnecting}, or {@link AgentHostClientState.Closed}: * the transport is not available for normal liveness traffic in those states. + * An inbound message also clears any deferred liveness state. */ private _resetLivenessTimers(): void { this._cancelLivenessTimers(); @@ -2048,6 +2091,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private _cancelLivenessTimers(): void { this._pingTimer.cancel(); this._closeTimer.cancel(); + this._livenessDeferred = false; + this._livenessDeferredSince = undefined; } private _onPingTimer(): void { @@ -2062,6 +2107,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect void this.ping().catch(() => undefined); } + /** Rechecks deferrals promptly, then force-closes only after a fresh liveness window expires. */ private _onCloseTimer(): void { if (this._state.kind === AgentHostClientState.Incompatible || this._state.kind === AgentHostClientState.Closed @@ -2072,15 +2118,16 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect // The main process reports actual child-process exits explicitly. return; } - // {@link ILoadEstimator} guards against the *local* side of the - // confusion: if our own JS event loop has been pegged we suppress - // the close — the silence is on our end, not the remote's, and - // tearing down the transport would just abort in-flight requests. - // Re-arm only the close timer at {@link PING_INTERVAL_MS} so we - // re-evaluate promptly once load normalizes (rather than waiting a - // full PING_INTERVAL + LIVENESS_TIMEOUT window). - if (this._loadEstimator.hasHighLoad()) { - this._closeTimer.cancelAndSet(() => this._onCloseTimer(), PING_INTERVAL_MS); + const pendingReverseRequests = this._pendingReverseRequests.get(this._transport) ?? 0; + if (pendingReverseRequests > 0 || this._loadEstimator.hasHighLoad()) { + this._deferLivenessCheck(pendingReverseRequests); + return; + } + if (this._livenessDeferred) { + this._livenessDeferred = false; + this._livenessDeferredSince = undefined; + this._pingTimer.cancelAndSet(() => this._onPingTimer(), PING_INTERVAL_MS); + this._closeTimer.cancelAndSet(() => this._onCloseTimer(), PING_INTERVAL_MS + LIVENESS_TIMEOUT_MS); return; } const silence = Date.now() - this._lastReadTime; @@ -2103,6 +2150,20 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._handleClose(connectionTimeoutError(this._address, silence)); } + private _deferLivenessCheck(pendingReverseRequests: number): void { + const now = Date.now(); + if (!this._livenessDeferred) { + this._livenessDeferred = true; + this._livenessDeferredSince = now; + } + const deferredSince = this._livenessDeferredSince ?? now; + this._logService.trace( + `[RemoteAgentHostProtocol] Liveness: deferring close for ${now - deferredSince}ms; ${pendingReverseRequests} reverse request(s) outstanding.`, + ); + this._pingTimer.cancelAndSet(() => this._onPingTimer(), PING_INTERVAL_MS); + this._closeTimer.cancelAndSet(() => this._onCloseTimer(), PING_INTERVAL_MS); + } + /** * Get the next client sequence number for optimistic dispatch. */ diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index acd40c5a356853..582992adfe0c9e 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -4,7 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../nls.js'; -import { readSessionGitState, readSessionWorkspaceless, SessionLifecycle, type Changeset, type ISessionGitState, type ISessionWithDefaultChat, type URI } from './state/sessionState.js'; +import { readAgentMergeSessionState } from './agentMerge.js'; +import { isAgentMergeMessage } from './meta/agentMergeMessageMeta.js'; +import { AgentSystemNotificationKind, readAgentSystemNotificationMeta } from './meta/agentSystemNotificationMeta.js'; +import { MessageKind, readSessionGitState, readSessionWorkspaceless, ResponsePartKind, SessionLifecycle, type Changeset, type ISessionGitState, type ISessionWithDefaultChat, type URI } from './state/sessionState.js'; /** * Helpers for building / parsing the URI clients subscribe to in order to @@ -36,6 +39,9 @@ const UNCOMMITTED_CHANGESET_ID = 'uncommitted'; /** Stable id of the catalogue entry for the session-wide changeset. */ const SESSION_CHANGESET_ID = 'session'; +/** Stable id and change kind of the Agent Merge changeset. */ +export const AGENT_MERGE_CHANGESET_ID = 'agent-merge'; + /** Path prefix used by per-turn changeset URIs (`turn/`). */ const TURN_CHANGESET_PREFIX = 'turn/'; @@ -78,6 +84,12 @@ export const compareTurnsChangesetLabel = (): string => localize('compareTurnsCh /** Localized human-readable description for the compare-turns changeset template entry. */ export const compareTurnsChangesetDescription = (): string => localize('compareTurnsChangeset.description', "Show changes made between different turns"); +/** Localized human-readable label for the Agent Merge changeset entry. */ +const agentMergeChangesetLabel = (): string => localize('agentMergeChangeset.label', "Agent Merge Changes"); + +/** Localized human-readable description for the Agent Merge changeset entry. */ +const agentMergeChangesetDescription = (): string => localize('agentMergeChangeset.description', "Show changes made by Agent Merge since the last user message"); + /** * Returns the description shown next to the `Branch Changes` catalogue * entry. Prefers `${branchName} → ${baseBranchName}` when both values @@ -293,10 +305,8 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig } /** - * Builds the default ordered `summary.changesets` catalogue for a - * session (`Branch Changes`, `Uncommitted Changes`, `This Turn`) with - * label + uriTemplate only. Aggregate counts are filled in later by the - * diff producer as compute passes complete. + * Builds the ordered `summary.changesets` catalogue for a session. Aggregate + * counts are filled in later by the diff producer as compute passes complete. * * The first two entries (`Branch Changes`, `Uncommitted Changes`) are * git-only; `AgentService._attachGitState` strips them asynchronously @@ -304,11 +314,9 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig * per-changeset states are still registered for every session — only * the catalogue advertisements are stripped. * - * The compare-turns changeset (built by - * {@link buildCompareTurnsChangesetUri}) is intentionally NOT included - * in the default catalogue: it is subscribe-only. Clients that want - * compare-turns diffs construct the URI themselves from two known - * turn ids and subscribe directly. + * The Agent Merge entry reuses the compare-turns URI template. It is advertised + * after Agent Merge is enabled and remains available for the rest of the + * session, including after Agent Merge is disabled. */ export function buildDefaultChangesetCatalog(sessionUri: URI, state?: ISessionWithDefaultChat): Changeset[] { // Session that failed to create @@ -333,6 +341,14 @@ export function buildDefaultChangesetCatalog(sessionUri: URI, state?: ISessionWi } const gitState = readSessionGitState(state._meta); + const agentMergeChangeset = shouldAdvertiseAgentMergeChangeset(state) + ? [{ + label: agentMergeChangesetLabel(), + description: agentMergeChangesetDescription(), + uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), + changeKind: AGENT_MERGE_CHANGESET_ID, + }] satisfies Changeset[] + : []; if (!gitState) { // No git repository @@ -347,7 +363,8 @@ export function buildDefaultChangesetCatalog(sessionUri: URI, state?: ISessionWi description: thisTurnChangesetDescription(), uriTemplate: buildTurnChangesetUriTemplate(sessionUri), changeKind: ChangesetKind.Turn - }] satisfies Changeset[]; + }, + ...agentMergeChangeset] satisfies Changeset[]; } return [ @@ -383,6 +400,20 @@ export function buildDefaultChangesetCatalog(sessionUri: URI, state?: ISessionWi description: compareTurnsChangesetDescription(), uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), changeKind: ChangesetKind.Compare - } + }, + ...agentMergeChangeset ] satisfies Changeset[]; } + +function shouldAdvertiseAgentMergeChangeset(state: ISessionWithDefaultChat): boolean { + if (readAgentMergeSessionState(state.config?.values)?.enabled === true + || state.changesets?.some(changeset => changeset.changeKind === AGENT_MERGE_CHANGESET_ID)) { + return true; + } + + return state.turns.some(turn => + (turn.message.origin.kind === MessageKind.SystemNotification && isAgentMergeMessage(turn.message)) + || turn.responseParts.some(part => + part.kind === ResponsePartKind.SystemNotification + && readAgentSystemNotificationMeta(part).kind === AgentSystemNotificationKind.AgentMergeEnabled)); +} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index a1cae52c6855de..5ab584683d01ad 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -14,7 +14,8 @@ import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChang import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { isAhpChatChannel, parseSubagentSessionUri } from '../common/state/sessionState.js'; +import { readAgentMergeSessionState } from '../common/agentMerge.js'; +import { isAhpChatChannel, parseSubagentSessionUri, type SessionConfigState } from '../common/state/sessionState.js'; /** * Raw metadata blob values for the session DB, batch-read by the caller. @@ -57,6 +58,7 @@ export class AgentHostChangesetCoordinator extends Disposable { this._register(gitStateService.onDidRefreshSessionGitState(sessionStr => this.onDidRunSessionGitStateRefresh(sessionStr))); this._register(gitStateService.onDidChangeSessionGitHubState(sessionStr => this._changesetOperationService.updateOperations(sessionStr))); this._register(this._stateManager.onDidChangeSessionWorkingDirectories(({ session }) => this.onDidChangeSessionWorkingDirectories(session))); + this._register(this._stateManager.onDidChangeSessionConfig(event => this.onDidChangeSessionConfig(event.session, event.previous, event.current))); } // ---- Lifecycle hooks ---------------------------------------------------- @@ -91,6 +93,11 @@ export class AgentHostChangesetCoordinator extends Disposable { this._changesetFileMonitor.onSessionRestored(sessionStr); } + /** Refreshes config-dependent catalogue entries after restored session config is seeded. */ + onSessionConfigRestored(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); + } + /** * Called when a provisional session is materialized (working directory * becomes known). Recomputes every current changeset subscription. @@ -117,6 +124,14 @@ export class AgentHostChangesetCoordinator extends Disposable { this._changesetOperationService.updateOperations(sessionStr); } + private onDidChangeSessionConfig(session: string, previous: SessionConfigState | undefined, current: SessionConfigState | undefined): void { + const wasEnabled = readAgentMergeSessionState(previous?.values)?.enabled === true; + const isEnabled = readAgentMergeSessionState(current?.values)?.enabled === true; + if (wasEnabled !== isEnabled) { + this._changesets.refreshChangesetCatalog(session); + } + } + // ---- Subscription hooks ------------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts index 92b32a36cd4875..aab0838ef15c8f 100644 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ b/src/vs/platform/agentHost/node/agentPluginManager.ts @@ -12,7 +12,25 @@ import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentP import { CustomizationLoadStatus, type ClientPluginCustomization, type PluginCustomization } from '../common/state/sessionState.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; -const DEFAULT_MAX_PLUGINS = 20; +/** + * Cap on the total number of materialized plugin revisions kept on disk, + * across all plugins. Bounds disk usage; the LRU decides which revisions + * survive, so a plugin that is actively synced keeps more of its history + * than one that has gone idle. + */ +const DEFAULT_MAX_PLUGIN_REVISIONS = 64; + +/** + * Revisions retained per plugin URI before older ones are evicted. + * + * A client's nonce is a hash of the bundle's contents, so it is not + * monotonic: a customization set that changes and then changes back + * produces a nonce that was already synced. Retaining only the current + * revision turned every such cycle into a full re-copy of the bundle over + * the agent host connection. Keeping a short history makes those cycles + * cache hits instead. + */ +const MAX_REVISIONS_PER_PLUGIN = 8; /** On-disk cache entry format. */ interface ICacheEntry { @@ -34,9 +52,12 @@ interface ICacheEntry { * plugin are serialized and cannot clobber each other. * * Older nonces of a plugin are evicted opportunistically: when the manager - * starts up and again after each fresh sync of the same plugin. If a stale - * nonce directory cannot be removed (e.g. it is still locked), it is retained - * in the LRU and retried on a later cleanup pass. + * starts up and again after each fresh sync of the same plugin. Up to + * {@link MAX_REVISIONS_PER_PLUGIN} revisions are retained so that a + * customization set which cycles back to a previously synced state is a cache + * hit rather than a full re-copy. If a stale nonce directory cannot be removed + * (e.g. it is still locked), it is retained in the LRU and retried on a later + * cleanup pass. * * The LRU (which records each plugin's URI and nonce) is persisted to a JSON * file in the base path so it survives process restarts. @@ -46,7 +67,7 @@ export class AgentPluginManager implements IAgentPluginManager { private readonly _basePath: URI; private readonly _cachePath: URI; - private readonly _maxPlugins: number; + private readonly _maxRevisions: number; /** Serializes concurrent sync operations per plugin URI. */ private readonly _sequencer = new SequencerByKey(); @@ -64,11 +85,11 @@ export class AgentPluginManager implements IAgentPluginManager { userDataPath: URI, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, - maxPlugins: number = DEFAULT_MAX_PLUGINS, + maxRevisions: number = DEFAULT_MAX_PLUGIN_REVISIONS, ) { this._basePath = URI.joinPath(userDataPath, 'agentPlugins'); this._cachePath = URI.joinPath(this._basePath, 'cache.json'); - this._maxPlugins = maxPlugins; + this._maxRevisions = maxRevisions; } get basePath(): URI { @@ -121,6 +142,10 @@ export class AgentPluginManager implements IAgentPluginManager { if (ref.nonce && this._findEntry(ref.uri, ref.nonce) && await this._fileService.exists(destDir)) { this._touchLru(ref.uri, ref.nonce); this._logService.trace(`[AgentPluginManager] Nonce match for ${ref.uri}, skipping copy`); + // Persist the reordering: retention now keeps several revisions per + // plugin, so an unpersisted touch would reload in the pre-hit order + // and evict the revision that was most recently used. + await this._persistCache(); return destDir; } @@ -211,16 +236,17 @@ export class AgentPluginManager implements IAgentPluginManager { } /** - * Attempts to evict every nonce of {@link uri} except the most recently used - * one. Entries whose directory cannot be removed are left in the LRU so they - * can be retried later, once whatever was holding them has released them. + * Attempts to evict revisions of {@link uri} beyond the most recent + * {@link MAX_REVISIONS_PER_PLUGIN}. Entries whose directory cannot be + * removed are left in the LRU so they can be retried later, once whatever + * was holding them has released them. */ private async _cleanupStaleNoncesFor(uri: string): Promise { const entries = this._lru.filter(entry => entry.uri === uri); - // `entries` preserves LRU order; the last is the current revision. - const stale = entries.slice(0, -1); + // `entries` preserves LRU order; the tail holds the revisions we keep. + const stale = entries.slice(0, -MAX_REVISIONS_PER_PLUGIN); for (const entry of stale) { - this._logService.info(`[AgentPluginManager] Evicting stale nonce for plugin: ${uri}`); + this._logService.info(`[AgentPluginManager] Evicting stale nonce ${entry.nonce || 'default'} for plugin: ${uri}`); if (await this._tryDeleteDir(this._dirFor(entry.uri, entry.nonce))) { this._removeEntryRef(entry); } @@ -233,9 +259,9 @@ export class AgentPluginManager implements IAgentPluginManager { // are kept in the LRU so they can be retried on a later eviction // pass; the cap may be exceeded temporarily in that case. let i = 0; - while (this._lru.length > this._maxPlugins && i < this._lru.length) { + while (this._lru.length > this._maxRevisions && i < this._lru.length) { const candidate = this._lru[i]; - this._logService.info(`[AgentPluginManager] Evicting plugin: ${candidate.uri}`); + this._logService.info(`[AgentPluginManager] Evicting revision ${candidate.nonce || 'default'} of plugin: ${candidate.uri}`); if (await this._tryDeleteDir(this._dirFor(candidate.uri, candidate.nonce))) { this._lru.splice(i, 1); if (!this._lru.some(entry => entry.uri === candidate.uri)) { @@ -277,10 +303,38 @@ export class AgentPluginManager implements IAgentPluginManager { this._logService.warn('[AgentPluginManager] Failed to load cache from disk', err); } + await this._pruneMissingEntries(); await this._cleanupStaleNonces(); await this._persistCache(); } + /** + * Drops entries whose directory is gone (deleted out from under us, or a + * copy that never completed). Such an entry can never produce a cache hit, + * so leaving it in place would waste a per-plugin retention slot and a slot + * against the global cap. + */ + private async _pruneMissingEntries(): Promise { + const present = await Promise.all(this._lru.map(async entry => { + try { + await this._fileService.stat(this._dirFor(entry.uri, entry.nonce)); + return true; + } catch (err) { + // Only a confirmed absence justifies dropping the entry. + // `exists()` reports false for transient I/O and permission + // failures too, which would evict a still-valid revision and + // force a full re-copy of the bundle later. + return toFileOperationResult(err) !== FileOperationResult.FILE_NOT_FOUND; + } + })); + for (let i = this._lru.length - 1; i >= 0; i--) { + if (!present[i]) { + this._logService.trace(`[AgentPluginManager] Dropping cache entry with no directory: ${this._lru[i].uri}`); + this._lru.splice(i, 1); + } + } + } + private async _persistCache(): Promise { try { // Write entries in LRU order (oldest first) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 4cbb70f842b2ac..831af086f011c5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3259,6 +3259,7 @@ export class AgentService extends Disposable implements IAgentService { if (!hasCompletedTurn && !activeTurn) { throw new Error(`[AgentService] createChat: side chat source turn ${sideChat.turnId} not found in ${sourceKey}`); } + const sourceHasProviderFork = this._stateManager.getChatOrigin(sourceChatKey)?.kind !== ChatOriginKind.Tool; let anchorTurnId: string | undefined; if (activeTurn) { anchorTurnId = resolveLastNonLocalTurnId(sourceState?.turns ?? [], turnId => this._localTurns.isLocal(sourceChatKey, turnId)); @@ -3278,7 +3279,7 @@ export class AgentService extends Disposable implements IAgentService { ...(selection ? { selection } : {}), }, sourceChat: sourceChatKey, - shouldFork: !activeTurn || anchorTurnId !== undefined, + shouldFork: sourceHasProviderFork && (!activeTurn || anchorTurnId !== undefined), ...(selection ? { selection } : {}), ...(anchorTurnId ? { anchorTurnId } : {}), }; @@ -5745,6 +5746,7 @@ export class AgentService extends Disposable implements IAgentService { ]); if (restoredConfig) { this._stateManager.setSessionConfig(sessionStr, restoredConfig); + this._changesetCoordinator.onSessionConfigRestored(sessionStr); // Seeded config bypasses `onDidChangeSessionConfig`, so heal the // index for a session enabled before it was introduced. this._syncAgentMergeIndex(session, undefined, restoredConfig); diff --git a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts index 78e8e271743626..5f7ec7fefe16ca 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts @@ -39,12 +39,11 @@ export class SideChatContribution extends Disposable implements IAgentHostChatCo const sourceState = this._stateManager.getChatState(origin.chat); const activeTurn = sourceState?.activeTurn?.id === origin.turnId ? sourceState.activeTurn : undefined; - const forkAnchorTurnId = activeTurn + const sourceIsToolChat = this._stateManager.getChatOrigin(origin.chat)?.kind === ChatOriginKind.Tool; + const forkAnchorTurnId = activeTurn && !sourceIsToolChat ? resolveLastNonLocalTurnId(sourceState?.turns ?? [], turnId => this._localTurns.isLocal(origin.chat, turnId)) : undefined; - // A completed SDK-backed turn is already carried by the provider's fork. - // Only active and host-injected local turns are missing from that history. - const sourceContext = activeTurn || this._localTurns.isLocal(origin.chat, origin.turnId) + const sourceContext = sourceIsToolChat || activeTurn || this._localTurns.isLocal(origin.chat, origin.turnId) ? buildBoundedSideChatSourceContext(sourceState?.turns ?? [], origin.turnId, activeTurn, forkAnchorTurnId) : undefined; const partialResponse = getSideChatPartialResponse(activeTurn); diff --git a/src/vs/platform/agentHost/test/common/changesetUri.test.ts b/src/vs/platform/agentHost/test/common/changesetUri.test.ts index d308bb6b98c6f6..342c58f1cf4bf1 100644 --- a/src/vs/platform/agentHost/test/common/changesetUri.test.ts +++ b/src/vs/platform/agentHost/test/common/changesetUri.test.ts @@ -5,11 +5,17 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { toAgentMergeMessageMeta } from '../../common/meta/agentMergeMessageMeta.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TurnState, type ISessionWithDefaultChat, type Turn } from '../../common/state/sessionState.js'; import { + AGENT_MERGE_CHANGESET_ID, ChangesetKind, buildChangesetUri, buildCompareTurnsChangesetUri, buildCompareTurnsChangesetUriTemplate, + buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildTurnChangesetUriTemplate, @@ -29,6 +35,39 @@ suite('changesetUri', () => { const sessionUri = 'copilot:/abc-123'; + function turn(id: string, kind: MessageKind, agentMerge = false): Turn { + return { + id, + message: { + text: id, + origin: { kind }, + ...(agentMerge ? { _meta: toAgentMergeMessageMeta() } : {}), + }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }; + } + + function state(agentMergeEnabled?: boolean, turns: Turn[] = [], changesets?: ISessionWithDefaultChat['changesets']): ISessionWithDefaultChat { + return { + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + turns, + changesets, + ...(agentMergeEnabled === undefined ? {} : { + config: { + schema: { type: 'object', properties: {} }, + values: { [SessionConfigKey.AgentMerge]: { enabled: agentMergeEnabled } }, + }, + }), + }; + } + test('builders produce the documented shapes', () => { assert.strictEqual(buildSessionChangesetUri(sessionUri), 'copilot:/abc-123/changeset/session'); assert.strictEqual(buildUncommittedChangesetUri(sessionUri), 'copilot:/abc-123/changeset/uncommitted'); @@ -122,4 +161,53 @@ suite('changesetUri', () => { assert.strictEqual(isUncommittedChangesetUri(buildUncommittedChangesetUri(sessionUri)), true); assert.strictEqual(isUncommittedChangesetUri(buildSessionChangesetUri(sessionUri)), false); }); + + test('advertises Agent Merge changes after enablement and preserves them across disable and restore', () => { + const enabledCatalog = buildDefaultChangesetCatalog(sessionUri, state(true)); + const enabledNotice = turn('notice', MessageKind.SystemNotification); + enabledNotice.responseParts.push({ + kind: ResponsePartKind.SystemNotification, + content: 'Agent Merge enabled', + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AgentMergeEnabled }), + }); + + const findAgentMerge = (catalog: ReturnType) => + catalog.find(changeset => changeset.changeKind === AGENT_MERGE_CHANGESET_ID); + + assert.deepStrictEqual({ + neverEnabled: findAgentMerge(buildDefaultChangesetCatalog(sessionUri, state())), + configuredWhileDisabled: findAgentMerge(buildDefaultChangesetCatalog(sessionUri, state(false))), + enabled: findAgentMerge(enabledCatalog), + disabledAfterEnable: findAgentMerge(buildDefaultChangesetCatalog(sessionUri, state(false, [], enabledCatalog))), + restoredFromRepairTurn: findAgentMerge(buildDefaultChangesetCatalog(sessionUri, state(undefined, [turn('repair', MessageKind.SystemNotification, true)]))), + restoredFromEnabledNotice: findAgentMerge(buildDefaultChangesetCatalog(sessionUri, state(undefined, [enabledNotice]))), + }, { + neverEnabled: undefined, + configuredWhileDisabled: undefined, + enabled: { + label: 'Agent Merge Changes', + description: 'Show changes made by Agent Merge since the last user message', + uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), + changeKind: AGENT_MERGE_CHANGESET_ID, + }, + disabledAfterEnable: { + label: 'Agent Merge Changes', + description: 'Show changes made by Agent Merge since the last user message', + uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), + changeKind: AGENT_MERGE_CHANGESET_ID, + }, + restoredFromRepairTurn: { + label: 'Agent Merge Changes', + description: 'Show changes made by Agent Merge since the last user message', + uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), + changeKind: AGENT_MERGE_CHANGESET_ID, + }, + restoredFromEnabledNotice: { + label: 'Agent Merge Changes', + description: 'Show changes made by Agent Merge since the last user message', + uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), + changeKind: AGENT_MERGE_CHANGESET_ID, + }, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 2de99cfa54a49d..5533239c9b2bc3 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -280,6 +280,7 @@ suite('AgentHostProtocolClient', () => { onGrantImplicitRead?: (identity: AgentHostResourceIdentity, uri: URI) => void; /** Test hook that observes disposal of the implicit-read grant. */ onRevokeImplicitRead?: (identity: AgentHostResourceIdentity, uri: URI) => void; + onRead?: (identity: AgentHostResourceIdentity, uri: URI) => Promise<{ bytes: VSBuffer }>; readBytes?: VSBuffer; } @@ -307,6 +308,9 @@ suite('AgentHostProtocolClient', () => { async list(addr, uri) { await gateRead(addr, uri); return { entries: [] }; }, async read(addr, uri) { await gateRead(addr, uri); + if (opts.onRead) { + return await opts.onRead(addr, uri); + } if (opts.readBytes) { return { bytes: opts.readBytes }; } @@ -2272,7 +2276,7 @@ suite('AgentHostProtocolClient', () => { * client plus a `transports` array recording each transport handed * out, so tests can drive handshake/reconnect interactions. */ - function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy): { client: AgentHostProtocolClient; transports: TestClientProtocolTransport[] } { + function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy, loadEstimator?: { hasHighLoad(): boolean }): { client: AgentHostProtocolClient; transports: TestClientProtocolTransport[] } { const transports: TestClientProtocolTransport[] = []; const factory = () => { const t = disposables.add(new TestClientProtocolTransport()); @@ -2280,7 +2284,7 @@ suite('AgentHostProtocolClient', () => { return t; }; const client = disposables.add(new AgentHostProtocolClient( - 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined ? { clientInfo, reconnectPolicy } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, + 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, )); return { client, transports }; } @@ -3306,7 +3310,9 @@ suite('AgentHostProtocolClient', () => { test('watchdog dead-transport detection triggers soft reconnect', async function () { this.timeout(60_000); return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { - const { client, transports } = createFactoryClient(); + // Inject a no-load estimator: the shared LoadEstimator singleton + // installs a 1s interval that never drains under fake timers. + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, undefined, { hasHighLoad: () => false }); const connectPromise = client.connect(); await completeHandshake(transports[0], connectPromise); @@ -3325,5 +3331,184 @@ suite('AgentHostProtocolClient', () => { assert.match((err as ProtocolError).message, /Connection appears dead/); }); }); + + test('watchdog grants a full liveness window after a pending reverse request is answered', async function () { + this.timeout(60_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const readDeferred = new DeferredPromise<{ bytes: VSBuffer }>(); + const { client, transports } = createFactoryClient(createResourceServiceStub({ + onRead: () => readDeferred.p, + }), undefined, NullTelemetryService, undefined, { hasHighLoad: () => false }); + try { + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireMessage({ + jsonrpc: '2.0', + id: 1, + method: 'resourceRead', + params: { channel: 'ahp-root://', uri: URI.file('/workspace/customization.json').toString() }, + }); + + await timeout(25_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'watchdog must not close while the host awaits a reverse-request response'); + + readDeferred.complete({ bytes: VSBuffer.fromString('{}') }); + await flushMicrotasks(); + + // The window now runs from the moment the response was handed + // to the transport, so it expires at ~t+25s rather than at the + // next 5s poll plus 25s. + await timeout(20_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'watchdog must grant a full liveness window once the pending reverse request is answered'); + + await timeout(10_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Reconnecting, + 'watchdog must close after the fresh liveness window expires without inbound traffic'); + } finally { + client.dispose(); + } + }); + }); + + test('watchdog grants a full liveness window when a reverse request is answered before the first close tick', async function () { + this.timeout(60_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const readDeferred = new DeferredPromise<{ bytes: VSBuffer }>(); + const { client, transports } = createFactoryClient(createResourceServiceStub({ + onRead: () => readDeferred.p, + }), undefined, NullTelemetryService, undefined, { hasHighLoad: () => false }); + try { + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireMessage({ + jsonrpc: '2.0', + id: 1, + method: 'resourceRead', + params: { channel: 'ahp-root://', uri: URI.file('/workspace/customization.json').toString() }, + }); + + // Answer just before the close timer armed by the inbound + // request fires, so no deferral is ever observed. The peer is + // still owed the drain plus its own post-response work, so the + // close must not fire moments later. + await timeout(24_000); + readDeferred.complete({ bytes: VSBuffer.fromString('{}') }); + await flushMicrotasks(); + + await timeout(5_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'watchdog must not close right after a response it never observed as deferred'); + + await timeout(15_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'the granted window must run from the response, not from the last inbound message'); + + await timeout(10_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Reconnecting, + 'watchdog must still close once the granted window expires'); + } finally { + client.dispose(); + } + }); + }); + + test('watchdog retains deferral until all concurrent reverse requests are answered', async function () { + this.timeout(60_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const firstRead = new DeferredPromise<{ bytes: VSBuffer }>(); + const secondRead = new DeferredPromise<{ bytes: VSBuffer }>(); + const { client, transports } = createFactoryClient(createResourceServiceStub({ + onRead: (_identity, uri) => uri.path === '/workspace/one.json' ? firstRead.p : secondRead.p, + }), undefined, NullTelemetryService, undefined, { hasHighLoad: () => false }); + try { + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireMessage({ jsonrpc: '2.0', id: 1, method: 'resourceRead', params: { channel: 'ahp-root://', uri: URI.file('/workspace/one.json').toString() } }); + transports[0].fireMessage({ jsonrpc: '2.0', id: 2, method: 'resourceRead', params: { channel: 'ahp-root://', uri: URI.file('/workspace/two.json').toString() } }); + + await timeout(25_000); + firstRead.complete({ bytes: VSBuffer.fromString('{}') }); + await flushMicrotasks(); + await timeout(5_000); + + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'watchdog must remain deferred while another reverse request is outstanding'); + + secondRead.complete({ bytes: VSBuffer.fromString('{}') }); + await flushMicrotasks(); + + // The window runs from the final response, so it expires ~25s + // after this point rather than after the next poll. + await timeout(20_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'watchdog must grant a fresh liveness window after the final reverse request completes'); + + await timeout(10_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Reconnecting, + 'watchdog must close once every concurrent reverse request has completed and the fresh window expires'); + } finally { + client.dispose(); + } + }); + }); + + test('watchdog clears reverse-request deferral after an error response', async function () { + this.timeout(60_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const { client, transports } = createFactoryClient(createResourceServiceStub({ + onRead: async () => { throw new Error('resource read failed'); }, + }), undefined, NullTelemetryService, undefined, { hasHighLoad: () => false }); + try { + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireMessage({ jsonrpc: '2.0', id: 1, method: 'resourceRead', params: { channel: 'ahp-root://', uri: URI.file('/workspace/error.json').toString() } }); + await flushMicrotasks(); + + assert.deepStrictEqual(transports[0].sentMessages.at(-1), { + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message: 'resource read failed' }, + }); + + await timeout(25_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Reconnecting, + 'watchdog must close when a reverse request has completed with an error'); + } finally { + client.dispose(); + } + }); + }); + + test('watchdog grants a full liveness window after high load clears', async function () { + this.timeout(60_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + let hasHighLoad = true; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, undefined, { hasHighLoad: () => hasHighLoad }); + try { + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + await timeout(25_000); + hasHighLoad = false; + await timeout(5_000); + await timeout(20_000); + + assert.strictEqual(client.connectionState, AgentHostClientState.Connected, + 'watchdog must grant a fresh liveness window once high load clears'); + + await timeout(5_000); + assert.strictEqual(client.connectionState, AgentHostClientState.Reconnecting, + 'watchdog must close after the fresh liveness window expires without inbound traffic'); + } finally { + client.dispose(); + } + }); + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 35bbf6cfe1944b..09df69fc13bc6c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -12,6 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff, type ISessionGitHubState } from '../../common/state/sessionState.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -117,6 +118,28 @@ suite('ChangesetSessionCoordinator', () => { assert.deepStrictEqual(environment.updateOperationsCalls.slice(afterAdd), [session], 'removing a root refreshes the session operations'); }); + test('refreshes the changeset catalogue when Agent Merge enablement changes', () => { + const { stateManager, changesets } = createEnvironment(); + const session = AgentSession.uri('mock', 'agent-merge-catalog').toString(); + createSession(stateManager, session); + stateManager.setSessionConfig(session, { schema: { type: 'object', properties: {} }, values: {} }); + + stateManager.dispatchServerAction(session, { + type: ActionType.SessionConfigChanged, + config: { mode: 'interactive' }, + }); + stateManager.dispatchServerAction(session, { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.AgentMerge]: { enabled: true } }, + }); + stateManager.dispatchServerAction(session, { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.AgentMerge]: { enabled: false } }, + }); + + assert.deepStrictEqual(changesets.catalogRefreshes, [session, session]); + }); + test('refreshes changeset operations when GitHub state changes', () => { const session = AgentSession.uri('mock', 'session-github').toString(); const environment = createEnvironment(); @@ -943,6 +966,7 @@ class TestChangesetService implements IAgentHostChangesetService { declare readonly _serviceBrand: undefined; readonly branchRefreshes: string[] = []; + readonly catalogRefreshes: string[] = []; readonly uncommittedRefreshes: string[] = []; readonly sessionRefreshes: string[] = []; readonly workingDirectoryAvailable: string[] = []; @@ -957,7 +981,9 @@ class TestChangesetService implements IAgentHostChangesetService { restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } isStaticChangesetComputeActive(_changesetUri: string): boolean { return false; } - refreshChangesetCatalog(_session: string): void { } + refreshChangesetCatalog(session: string): void { + this.catalogRefreshes.push(session); + } refreshBranchChangeset(session: string): void { this.branchRefreshes.push(session); } diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts index 768a7af7c47b89..360847e01df97a 100644 --- a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts @@ -168,7 +168,7 @@ suite('AgentPluginManager', () => { assert.strictEqual(result1[0].pluginDir!.toString(), result2[0].pluginDir!.toString()); }); - test('new nonce materializes a fresh subdirectory and evicts the stale one', async () => { + test('new nonce materializes a fresh subdirectory and retains the previous one', async () => { await seedPluginDir('rev', { 'index.js': 'v1' }); const r1 = await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-1')]); @@ -181,8 +181,39 @@ suite('AgentPluginManager', () => { assert.notStrictEqual(dir1.toString(), dir2.toString(), 'new nonce should use a new subdirectory'); assert.strictEqual(await fileService.exists(dir2), true, 'new nonce subdirectory should exist'); - assert.strictEqual(await fileService.exists(dir1), false, 'stale nonce subdirectory should be evicted'); - assert.deepStrictEqual(await readCacheNonces(), new Set(['nonce-2'])); + assert.strictEqual(await fileService.exists(dir1), true, 'superseded nonce should be retained within the window'); + assert.deepStrictEqual(await readCacheNonces(), new Set(['nonce-1', 'nonce-2'])); + }); + + test('a nonce that cycles back to a retained revision is a cache hit', async () => { + await seedPluginDir('rev', { 'index.js': 'v1' }); + const r1 = await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-1')]); + const dir1 = r1[0].pluginDir!; + + await seedPluginDir('rev', { 'index.js': 'v2' }); + await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-2')]); + + // Back to the original content. The source no longer matters: a hit + // must reuse the retained directory rather than re-copying. + await fileService.del(toAgentClientUri(URI.from({ scheme: Schemas.inMemory, path: '/plugins/rev' }), 'test-client'), { recursive: true }); + const r3 = await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-1')]); + + assert.strictEqual(r3[0].pluginDir?.toString(), dir1.toString()); + assert.strictEqual((r3[0].customization as PluginCustomization).load?.kind, 'loaded'); + assert.strictEqual((await fileService.readFile(URI.joinPath(dir1, 'index.js'))).value.toString(), 'v1'); + }); + + test('evicts the oldest revision once the per-plugin retention window is exceeded', async () => { + // One more revision than the retention window (8). + for (let i = 1; i <= 9; i++) { + await seedPluginDir('rev', { 'index.js': `v${i}` }); + await manager.syncCustomizations('test-client', [makeRef('rev', `nonce-${i}`)]); + } + + assert.deepStrictEqual( + await readCacheNonces(), + new Set(['nonce-2', 'nonce-3', 'nonce-4', 'nonce-5', 'nonce-6', 'nonce-7', 'nonce-8', 'nonce-9']), + ); }); test('retains a locked older nonce so both revisions coexist', async () => { @@ -208,28 +239,33 @@ suite('AgentPluginManager', () => { const dir1 = r1[0].pluginDir!; provider.lockedPaths.add(dir1.path); - await seedPluginDir('rev', { 'index.js': 'v2' }); - await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-2')]); + // Push the locked revision out of the retention window so eviction + // is attempted (and fails) while the lock is held. + for (let i = 2; i <= 9; i++) { + await seedPluginDir('rev', { 'index.js': `v${i}` }); + await manager.syncCustomizations('test-client', [makeRef('rev', `nonce-${i}`)]); + } + assert.strictEqual(await fileService.exists(dir1), true, 'locked nonce should survive while held'); // Release the lock and start a fresh manager against the same base path. provider.lockedPaths.clear(); const manager2 = new AgentPluginManager(basePath, fileService, new NullLogService()); - await manager2.syncCustomizations('test-client', [makeRef('rev', 'nonce-2')]); + await manager2.syncCustomizations('test-client', [makeRef('rev', 'nonce-9')]); assert.strictEqual(await fileService.exists(dir1), false, 'released older nonce should be evicted on startup'); - assert.deepStrictEqual(await readCacheNonces(), new Set(['nonce-2'])); + assert.ok(!(await readCacheNonces()).has('nonce-1')); }); test('drops a stale cache entry when its directory is already gone', async () => { await seedPluginDir('rev', { 'index.js': 'v1' }); const r1 = await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-1')]); const dir1 = r1[0].pluginDir!; - provider.lockedPaths.add(dir1.path); await seedPluginDir('rev', { 'index.js': 'v2' }); await manager.syncCustomizations('test-client', [makeRef('rev', 'nonce-2')]); - provider.lockedPaths.clear(); + // nonce-1 is still inside the retention window, so only the missing + // directory itself can tell us the entry is worthless. await fileService.del(dir1, { recursive: true }); const manager2 = new AgentPluginManager(basePath, fileService, new NullLogService()); await manager2.syncCustomizations('test-client', [makeRef('rev', 'nonce-2')]); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 57b83d6f59e4ea..82a1f53f06d4b0 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -56,7 +56,7 @@ import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; import { type ISessionEvent } from './copilotTestEvents.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; -import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { AGENT_MERGE_CHANGESET_ID, buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import { getWorktreesRoot, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -10235,6 +10235,28 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('creates a fresh side chat from a completed tool-origin chat', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const sourceChat = buildSubagentChatUri(session, 'tool-1'); + getStateManager(service).addChat(session.toString(), sourceChat, { + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(session), toolCallId: 'tool-1' }, + turns: [completedTurn('t1')], + }); + const chatUri = URI.parse(buildChatUri(session, 'side-1')); + + await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 't1' } }); + + assert.deepStrictEqual({ + origin: getStateManager(service).getChatState(chatUri.toString())?.origin, + forkForwarded: agent.lastCreateOptions?.fork, + }, { + origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 't1' }, + forkForwarded: undefined, + }); + }); + test('creates a fresh peer with a SideChat origin and no copied source turns', async () => { const agent = disposables.add(new SideChatAgent('copilot')); registerTestAgentProvider(service, agent); @@ -15144,7 +15166,9 @@ suite('AgentService (node dispatcher)', () => { const sessionStr = sessionResource.toString(); // A fresh host over the same durable state must resume monitoring - // from the index alone. + // from the index alone. Remove best-effort local notices so the + // changeset catalogue can only recover from persisted config. + await sessionDb.deleteLocalTurns((await sessionDb.getLocalTurns()).map(turn => turn.turnId)); const restarted = createAgentMergeService(sessionDb, orchestratorDb); registerTestAgentProvider(restarted, localAgent); await restarted.whenAgentMergeSessionsRestored(); @@ -15154,6 +15178,7 @@ suite('AgentService (node dispatcher)', () => { // materialized and immediately disabled. enabled: readAgentMergeSessionState(getStateManager(restarted).getSessionState(sessionStr)?.config?.values)?.enabled, indexed: await orchestratorDb.listAgentMergeEnabledSessions(), + hasAgentMergeChangeset: getStateManager(restarted).getSessionState(sessionStr)?.changesets?.some(changeset => changeset.changeKind === AGENT_MERGE_CHANGESET_ID), }; // Nothing ever subscribed, so only the monitoring pin is holding @@ -15165,7 +15190,7 @@ suite('AgentService (node dispatcher)', () => { resumed, residentAfterDisable: getStateManager(restarted).getSessionState(sessionStr) !== undefined, }, { - resumed: { materialized: true, enabled: true, indexed: [sessionStr] }, + resumed: { materialized: true, enabled: true, indexed: [sessionStr], hasAgentMergeChangeset: true }, residentAfterDisable: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 931bb10c53427e..13c33567e8a619 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -699,11 +699,11 @@ function createContributions(disposables: ReturnType, inheritedTurnId?: string, selectionText?: string) { +function createSideChatContributions(disposables: ReturnType, inheritedTurnId?: string, selectionText?: string, options?: { readonly sourceIsToolChat?: boolean }) { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const session = 'agent-host-session://side-chat'; - const sourceChat = buildDefaultChatUri(session); + const sourceChat = options?.sourceIsToolChat ? buildSubagentChatUri(session, 'source-tool') : buildDefaultChatUri(session); const sideChat = buildChatUri(session, 'side'); stateManager.createSession({ resource: session, @@ -713,6 +713,12 @@ function createSideChatContributions(disposables: ReturnType { }); }); + test('includes source transcript for a completed tool-origin side-chat source turn', async () => { + const sideChat = createSideChatContributions(disposables, undefined, 'selected text', { sourceIsToolChat: true }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'source question', origin: { kind: MessageKind.User } }, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'source-turn', + duration: 1, + }); + + const first = await sideChat.service.outgoingTurn({ + session: sideChat.session, + chat: sideChat.sideChat, + message: { text: 'side question', origin: { kind: MessageKind.User } }, + turnId: 'side-turn', + }); + + assert.strictEqual(first.message.text, injectSideChatContext('side question', undefined, 'User request:\nsource question', 'selected text')); + }); + + test('keeps completed turns before an active tool-origin side-chat source turn', async () => { + const sideChat = createSideChatContributions(disposables, undefined, undefined, { sourceIsToolChat: true }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'completed-turn', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'completed question', origin: { kind: MessageKind.User } }, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'completed-turn', + duration: 1, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: '2025-01-01T00:00:01.000Z', + message: { text: 'active question', origin: { kind: MessageKind.User } }, + }); + + const first = await sideChat.service.outgoingTurn({ + session: sideChat.session, + chat: sideChat.sideChat, + message: { text: 'side question', origin: { kind: MessageKind.User } }, + turnId: 'side-turn', + }); + + assert.strictEqual(first.message.text, injectSideChatContext('side question', undefined, 'User request:\ncompleted question\n\n---\n\nUser request:\nactive question')); + }); + test('includes source transcript for an active side-chat source turn', async () => { const sideChat = createSideChatContributions(disposables); sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { 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 14789cd3287629..06b1c942cd0a2a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -276,6 +276,23 @@ A user can contribute lifecycle hooks through a client-pushed Copilot plugin to --grep "plugin .* hook|failing plugin hook|non-JSON plugin hook" ``` +### Copilot file edit metadata is lost after a host restart + +A user can reopen an Agent Host session and ask Copilot to edit a file. The file changes successfully, but the restored provider does not publish the before-and-after edit metadata, so the completed edit renders as a generic tool call instead of an edit pill and edit attribution is unavailable. + +- Test: `file edit metadata survives a host restart`. +- Scope: Copilot on all platforms. +- Expected: an edit made after the host restores the provider session includes readable before-and-after content references in the completed tool result. +- Observed: the edit tool succeeds, but its completed tool result contains no file edit metadata. +- Gate: the scenario requires `AGENT_HOST_RUN_KNOWN_ISSUES=1`. +- Reproduce: + + ```bash + AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "file edit metadata survives a host restart" + ``` + ### Client-pushed plugin MCP coverage is provider-scoped - Tests: the `client plugin …` and `plugin MCP …` scenarios in `mcpPluginSuite.ts`. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-github-remote-with-changes-advertises-pull-request-creation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-github-remote-with-changes-advertises-pull-request-creation.yaml new file mode 100644 index 00000000000000..7e545880af416c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-github-remote-with-changes-advertises-pull-request-creation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-archiving-a-never-restored-session-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-archiving-a-never-restored-session-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..710a5f7715188a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-archiving-a-never-restored-session-survives-a-host-restart.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml new file mode 100644 index 00000000000000..7e545880af416c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml new file mode 100644 index 00000000000000..c68f9fba9dc4c3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Agent Host guide — https://example.com/agent-host' + response: + content: recorded + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml new file mode 100644 index 00000000000000..a5f8e32009413a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.' + response: + content: rejected + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml new file mode 100644 index 00000000000000..68eda740449a2c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml @@ -0,0 +1,209 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: added + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + response: + content: + - type: tool_use + id: toolcall_1 + name: mcp__host__list_artifacts_and_references + input: {} + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + response: + content: listed + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + response: + content: + - type: tool_use + id: toolcall_2 + name: mcp__host__remove_artifact_or_reference + input: + id: ${uuid_0} + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + - role: assistant + content: + - type: tool_use + name: mcp__host__remove_artifact_or_reference + input: + id: ${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: 'Removed reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: removed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml new file mode 100644 index 00000000000000..b019114c44a4ca --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml @@ -0,0 +1,40 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__rename_chat + input: + title: Coverage audit + automatic: false + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + - role: assistant + content: + - type: thinking + - type: tool_use + name: mcp__host__rename_chat + input: + title: Coverage audit + automatic: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Renamed chat to "Coverage audit". + response: + content: renamed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-a-github-remote-with-changes-advertises-pull-request-creation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-a-github-remote-with-changes-advertises-pull-request-creation.yaml new file mode 100644 index 00000000000000..93eb80ff51fb0a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-a-github-remote-with-changes-advertises-pull-request-creation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-archiving-a-never-restored-session-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-archiving-a-never-restored-session-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..25d126cafb6c23 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-archiving-a-never-restored-session-survives-a-host-restart.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml new file mode 100644 index 00000000000000..93eb80ff51fb0a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml new file mode 100644 index 00000000000000..696583956cfc7c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + response: + content: + - type: text + text: Got it — I’ll record that reference now. + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + - role: assistant + content: Got it — I’ll record that reference now. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Agent Host guide — https://example.com/agent-host' + response: + content: recorded + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml new file mode 100644 index 00000000000000..d4ffe47aa466c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + response: + content: + - type: text + text: I’ll record that artifact now, then return the requested response. + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + - role: assistant + content: I’ll record that artifact now, then return the requested response. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Server tool add_artifact_or_reference failed: Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.' + response: + content: rejected + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml new file mode 100644 index 00000000000000..b02c9b371f3222 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml @@ -0,0 +1,221 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + response: + content: + - type: text + text: I’ll add that reference now, then confirm. + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: added + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + response: + content: + - type: tool_use + id: toolcall_1 + name: list_artifacts_and_references + input: {} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + response: + content: listed + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + response: + content: + - type: tool_use + id: toolcall_2 + name: remove_artifact_or_reference + input: + id: ${uuid_0} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + - role: assistant + content: + - type: tool_use + name: remove_artifact_or_reference + input: + id: ${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: 'Removed reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: removed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml new file mode 100644 index 00000000000000..ba17e83c76ad79 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + response: + content: + - type: tool_use + id: toolcall_0 + name: rename_chat + input: + title: Coverage audit + automatic: false + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + - role: assistant + content: + - type: tool_use + name: rename_chat + input: + title: Coverage audit + automatic: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Renamed chat to "Coverage audit". + response: + content: renamed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-a-github-remote-with-changes-advertises-pull-request-creation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-a-github-remote-with-changes-advertises-pull-request-creation.yaml new file mode 100644 index 00000000000000..8f0771a3980287 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-a-github-remote-with-changes-advertises-pull-request-creation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-archiving-a-never-restored-session-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-archiving-a-never-restored-session-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..7dfb7f7b74a81e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-archiving-a-never-restored-session-survives-a-host-restart.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml new file mode 100644 index 00000000000000..8f0771a3980287 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml new file mode 100644 index 00000000000000..97c73e5dd8a3a0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-file-edit-before-and-after-content-can-be-read-from-session-storage.yaml @@ -0,0 +1,41 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use edit exactly once to replace BEFORE_STORED_VALUE with AFTER_STORED_VALUE in ${workdir}/stored-edit.txt. Do not inspect or search for the file and do not run a shell command. Then reply exactly "done". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/stored-edit.txt + old_str: BEFORE_STORED_VALUE + new_str: AFTER_STORED_VALUE + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use edit exactly once to replace BEFORE_STORED_VALUE with AFTER_STORED_VALUE in ${workdir}/stored-edit.txt. Do not inspect or search for the file and do not run a shell command. Then reply exactly "done". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/stored-edit.txt + old_str: BEFORE_STORED_VALUE + new_str: AFTER_STORED_VALUE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/stored-edit.txt updated with changes. + response: + content: done + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-file-edit-metadata-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-file-edit-metadata-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..8eadfb2f6e39e1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-file-edit-metadata-survives-a-host-restart.yaml @@ -0,0 +1,58 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + - role: assistant + content: READY + - role: user + content: Use edit exactly once to replace BEFORE_RESTART with AFTER_RESTART in ${workdir}/stored-edit.txt. Do not inspect or search for the file and do not run a shell command. Then reply exactly "done". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/stored-edit.txt + old_str: BEFORE_RESTART + new_str: AFTER_RESTART + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + - role: assistant + content: READY + - role: user + content: Use edit exactly once to replace BEFORE_RESTART with AFTER_RESTART in ${workdir}/stored-edit.txt. Do not inspect or search for the file and do not run a shell command. Then reply exactly "done". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/stored-edit.txt + old_str: BEFORE_RESTART + new_str: AFTER_RESTART + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/stored-edit.txt updated with changes. + response: + content: done + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml new file mode 100644 index 00000000000000..b8b08ae422ac36 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + response: + content: + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Agent Host guide — https://example.com/agent-host' + response: + content: recorded + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml new file mode 100644 index 00000000000000..b220e05a9986dd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml @@ -0,0 +1,44 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + response: + content: + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + - role: assistant + content: + - type: thinking + - type: tool_use + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.' + response: + content: rejected + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml new file mode 100644 index 00000000000000..4debeeeb7cf3c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml @@ -0,0 +1,209 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + response: + content: + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: added + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + response: + content: + - type: tool_use + id: toolcall_1 + name: list_artifacts_and_references + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + response: + content: listed + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + response: + content: + - type: tool_use + id: toolcall_2 + name: remove_artifact_or_reference + input: + id: ${uuid_0} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + - role: assistant + content: + - type: tool_use + name: remove_artifact_or_reference + input: + id: ${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: 'Removed reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: removed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml new file mode 100644 index 00000000000000..0680aef842d5e9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + response: + content: + - type: tool_use + id: toolcall_0 + name: rename_chat + input: + title: Coverage audit + automatic: false + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + - role: assistant + content: + - type: tool_use + name: rename_chat + input: + title: Coverage audit + automatic: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Renamed chat to "Coverage audit". + response: + content: renamed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json index 534c5451f6c605..4e8654d0091024 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json @@ -5,35 +5,28 @@ "note": "A symbol is \"covered\" when an E2E test sends or receives it; this does not measure how deeply its semantics are asserted." }, "commands": { - "covered": 29, + "covered": 31, "total": 32, - "percentage": 90.62, + "percentage": 96.87, "uncovered": [ - "fetchAutomationRuns", - "listAutomationTriggerDefinitions", "runAutomation" ] }, "notifications": { - "covered": 4, + "covered": 5, "total": 8, - "percentage": 50, + "percentage": 62.5, "uncovered": [ - "auth/required", "otlp/exportMetrics", "otlp/exportTraces", "root/progress" ] }, "actions": { - "covered": 76, - "total": 95, - "percentage": 80, + "covered": 80, + "total": 96, + "percentage": 83.33, "uncovered": [ - "automation/createRequested", - "automation/removed", - "automation/set", - "automation/updateRequested", "automationRun/cancelRequested", "automationRun/lifecycleChanged", "automationRun/primarySessionChanged", @@ -45,6 +38,7 @@ "chat/reasoning", "chat/toolCallAuthRequired", "chat/toolCallAuthResolved", + "chat/turnResume", "session/activityChanged", "session/defaultChatChanged", "session/workingDirectoryReplaced", diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index e04e4b8fdd316e..5e66ac1fdc1143 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,32 +15,32 @@ }, "total": { "statements": { - "covered": 93666, - "total": 124793, - "percentage": 75.05 + "covered": 102765, + "total": 136497, + "percentage": 75.28 }, "branches": { - "covered": 10985, - "total": 16386, - "percentage": 67.03 + "covered": 12773, + "total": 18760, + "percentage": 68.08 }, "functions": { - "covered": 3534, - "total": 5026, - "percentage": 70.31 + "covered": 3932, + "total": 5585, + "percentage": 70.4 }, "lines": { - "covered": 93666, - "total": 124793, - "percentage": 75.05 + "covered": 102765, + "total": 136497, + "percentage": 75.28 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1227, - "total": 1246, - "percentage": 98.47 + "covered": 1280, + "total": 1299, + "percentage": 98.53 }, "branches": { "covered": 27, @@ -53,9 +53,9 @@ "percentage": 66.66 }, "lines": { - "covered": 1227, - "total": 1246, - "percentage": 98.47 + "covered": 1280, + "total": 1299, + "percentage": 98.53 } }, "src/vs/platform/agentHost/common/agentClientUri.ts": { @@ -126,8 +126,8 @@ }, "src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts": { "statements": { - "covered": 132, - "total": 132, + "covered": 162, + "total": 162, "percentage": 100 }, "branches": { @@ -141,15 +141,15 @@ "percentage": 100 }, "lines": { - "covered": 132, - "total": 132, + "covered": 162, + "total": 162, "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostChangesetService.ts": { "statements": { - "covered": 306, - "total": 306, + "covered": 295, + "total": 295, "percentage": 100 }, "branches": { @@ -163,15 +163,15 @@ "percentage": 100 }, "lines": { - "covered": 306, - "total": 306, + "covered": 295, + "total": 295, "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostChangesetSubscriptionService.ts": { "statements": { - "covered": 38, - "total": 38, + "covered": 47, + "total": 47, "percentage": 100 }, "branches": { @@ -185,11 +185,33 @@ "percentage": 100 }, "lines": { - "covered": 38, - "total": 38, + "covered": 47, + "total": 47, "percentage": 100 } }, + "src/vs/platform/agentHost/common/agentHostChatContributionsService.ts": { + "statements": { + "covered": 322, + "total": 324, + "percentage": 99.38 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 322, + "total": 324, + "percentage": 99.38 + } + }, "src/vs/platform/agentHost/common/agentHostCheckpointService.ts": { "statements": { "covered": 153, @@ -280,9 +302,9 @@ }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { "statements": { - "covered": 82, - "total": 98, - "percentage": 83.67 + "covered": 85, + "total": 109, + "percentage": 77.98 }, "branches": { "covered": 4, @@ -291,13 +313,13 @@ }, "functions": { "covered": 2, - "total": 3, - "percentage": 66.66 + "total": 4, + "percentage": 50 }, "lines": { - "covered": 82, - "total": 98, - "percentage": 83.67 + "covered": 85, + "total": 109, + "percentage": 77.98 } }, "src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts": { @@ -324,31 +346,31 @@ }, "src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts": { "statements": { - "covered": 30, - "total": 30, - "percentage": 100 + "covered": 88, + "total": 94, + "percentage": 93.61 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 1, + "total": 3, + "percentage": 33.33 }, "lines": { - "covered": 30, - "total": 30, - "percentage": 100 + "covered": 88, + "total": 94, + "percentage": 93.61 } }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { "statements": { - "covered": 403, - "total": 649, - "percentage": 62.09 + "covered": 421, + "total": 667, + "percentage": 63.11 }, "branches": { "covered": 39, @@ -356,14 +378,14 @@ "percentage": 68.42 }, "functions": { - "covered": 16, - "total": 24, - "percentage": 66.66 + "covered": 17, + "total": 25, + "percentage": 68 }, "lines": { - "covered": 403, - "total": 649, - "percentage": 62.09 + "covered": 421, + "total": 667, + "percentage": 63.11 } }, "src/vs/platform/agentHost/common/agentHostFileSystemService.ts": { @@ -390,9 +412,9 @@ }, "src/vs/platform/agentHost/common/agentHostGitService.ts": { "statements": { - "covered": 464, - "total": 477, - "percentage": 97.27 + "covered": 466, + "total": 479, + "percentage": 97.28 }, "branches": { "covered": 25, @@ -405,15 +427,15 @@ "percentage": 85.71 }, "lines": { - "covered": 464, - "total": 477, - "percentage": 97.27 + "covered": 466, + "total": 479, + "percentage": 97.28 } }, "src/vs/platform/agentHost/common/agentHostGitStateService.ts": { "statements": { - "covered": 63, - "total": 63, + "covered": 60, + "total": 60, "percentage": 100 }, "branches": { @@ -427,8 +449,8 @@ "percentage": 100 }, "lines": { - "covered": 63, - "total": 63, + "covered": 60, + "total": 60, "percentage": 100 } }, @@ -456,9 +478,9 @@ }, "src/vs/platform/agentHost/common/agentHostManagedSettings.ts": { "statements": { - "covered": 186, - "total": 306, - "percentage": 60.78 + "covered": 209, + "total": 341, + "percentage": 61.29 }, "branches": { "covered": 4, @@ -467,20 +489,20 @@ }, "functions": { "covered": 2, - "total": 10, - "percentage": 20 + "total": 11, + "percentage": 18.18 }, "lines": { - "covered": 186, - "total": 306, - "percentage": 60.78 + "covered": 209, + "total": 341, + "percentage": 61.29 } }, "src/vs/platform/agentHost/common/agentHostResourceService.ts": { "statements": { - "covered": 156, - "total": 161, - "percentage": 96.89 + "covered": 161, + "total": 166, + "percentage": 96.98 }, "branches": { "covered": 2, @@ -493,9 +515,9 @@ "percentage": 0 }, "lines": { - "covered": 156, - "total": 161, - "percentage": 96.89 + "covered": 161, + "total": 166, + "percentage": 96.98 } }, "src/vs/platform/agentHost/common/agentHostReviewService.ts": { @@ -522,14 +544,14 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 790, - "total": 881, - "percentage": 89.67 + "covered": 839, + "total": 928, + "percentage": 90.4 }, "branches": { - "covered": 51, - "total": 65, - "percentage": 78.46 + "covered": 53, + "total": 66, + "percentage": 80.3 }, "functions": { "covered": 15, @@ -537,9 +559,9 @@ "percentage": 68.18 }, "lines": { - "covered": 790, - "total": 881, - "percentage": 89.67 + "covered": 839, + "total": 928, + "percentage": 90.4 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -564,16 +586,38 @@ "percentage": 100 } }, + "src/vs/platform/agentHost/common/agentHostSubscriptionService.ts": { + "statements": { + "covered": 39, + "total": 39, + "percentage": 100 + }, + "branches": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 39, + "total": 39, + "percentage": 100 + } + }, "src/vs/platform/agentHost/common/agentHostTelemetry.ts": { "statements": { - "covered": 110, - "total": 141, - "percentage": 78.01 + "covered": 119, + "total": 150, + "percentage": 79.33 }, "branches": { "covered": 10, - "total": 31, - "percentage": 32.25 + "total": 32, + "percentage": 31.25 }, "functions": { "covered": 7, @@ -581,9 +625,9 @@ "percentage": 77.77 }, "lines": { - "covered": 110, - "total": 141, - "percentage": 78.01 + "covered": 119, + "total": 150, + "percentage": 79.33 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { @@ -610,24 +654,24 @@ }, "src/vs/platform/agentHost/common/agentHostUri.ts": { "statements": { - "covered": 142, - "total": 222, - "percentage": 63.96 + "covered": 186, + "total": 275, + "percentage": 67.63 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 1, + "total": 2, + "percentage": 50 }, "functions": { - "covered": 0, - "total": 8, - "percentage": 0 + "covered": 1, + "total": 12, + "percentage": 8.33 }, "lines": { - "covered": 142, - "total": 222, - "percentage": 63.96 + "covered": 186, + "total": 275, + "percentage": 67.63 } }, "src/vs/platform/agentHost/common/agentHostWorkingDirectories.ts": { @@ -654,36 +698,58 @@ }, "src/vs/platform/agentHost/common/agentMerge.ts": { "statements": { - "covered": 255, - "total": 472, - "percentage": 54.02 + "covered": 389, + "total": 826, + "percentage": 47.09 }, "branches": { + "covered": 4, + "total": 35, + "percentage": 11.42 + }, + "functions": { "covered": 3, - "total": 27, - "percentage": 11.11 + "total": 40, + "percentage": 7.5 + }, + "lines": { + "covered": 389, + "total": 826, + "percentage": 47.09 + } + }, + "src/vs/platform/agentHost/common/agentMergePrompt.ts": { + "statements": { + "covered": 113, + "total": 340, + "percentage": 33.23 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 2, - "total": 16, - "percentage": 12.5 + "covered": 0, + "total": 18, + "percentage": 0 }, "lines": { - "covered": 255, - "total": 472, - "percentage": 54.02 + "covered": 113, + "total": 340, + "percentage": 33.23 } }, "src/vs/platform/agentHost/common/agentModelByokMeta.ts": { "statements": { - "covered": 38, + "covered": 39, "total": 41, - "percentage": 92.68 + "percentage": 95.12 }, "branches": { - "covered": 1, - "total": 3, - "percentage": 33.33 + "covered": 4, + "total": 5, + "percentage": 80 }, "functions": { "covered": 1, @@ -691,16 +757,38 @@ "percentage": 50 }, "lines": { - "covered": 38, + "covered": 39, "total": 41, - "percentage": 92.68 + "percentage": 95.12 + } + }, + "src/vs/platform/agentHost/common/agentModelNotices.ts": { + "statements": { + "covered": 38, + "total": 64, + "percentage": 59.37 + }, + "branches": { + "covered": 1, + "total": 11, + "percentage": 9.09 + }, + "functions": { + "covered": 1, + "total": 3, + "percentage": 33.33 + }, + "lines": { + "covered": 38, + "total": 64, + "percentage": 59.37 } }, "src/vs/platform/agentHost/common/agentModelPricing.ts": { "statements": { - "covered": 178, - "total": 281, - "percentage": 63.34 + "covered": 194, + "total": 303, + "percentage": 64.02 }, "branches": { "covered": 4, @@ -713,9 +801,9 @@ "percentage": 37.5 }, "lines": { - "covered": 178, - "total": 281, - "percentage": 63.34 + "covered": 194, + "total": 303, + "percentage": 64.02 } }, "src/vs/platform/agentHost/common/agentModelSource.ts": { @@ -786,9 +874,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1070, - "total": 1237, - "percentage": 86.49 + "covered": 1094, + "total": 1262, + "percentage": 86.68 }, "branches": { "covered": 9, @@ -801,9 +889,9 @@ "percentage": 11.11 }, "lines": { - "covered": 1070, - "total": 1237, - "percentage": 86.49 + "covered": 1094, + "total": 1262, + "percentage": 86.68 } }, "src/vs/platform/agentHost/common/agentTelemetryCorrelation.ts": { @@ -835,9 +923,9 @@ "percentage": 85.82 }, "branches": { - "covered": 36, - "total": 45, - "percentage": 80 + "covered": 35, + "total": 44, + "percentage": 79.54 }, "functions": { "covered": 12, @@ -872,61 +960,105 @@ "percentage": 95.83 } }, - "src/vs/platform/agentHost/common/changesetUri.ts": { + "src/vs/platform/agentHost/common/autoModeTiers.ts": { "statements": { - "covered": 320, - "total": 370, - "percentage": 86.48 + "covered": 29, + "total": 45, + "percentage": 64.44 }, "branches": { - "covered": 49, - "total": 61, - "percentage": 80.32 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 19, - "total": 25, - "percentage": 76 + "covered": 0, + "total": 3, + "percentage": 0 }, "lines": { - "covered": 320, - "total": 370, - "percentage": 86.48 + "covered": 29, + "total": 45, + "percentage": 64.44 } }, - "src/vs/platform/agentHost/common/claudeModelConfig.ts": { + "src/vs/platform/agentHost/common/automationMigration.ts": { "statements": { - "covered": 123, - "total": 126, - "percentage": 97.61 + "covered": 25, + "total": 29, + "percentage": 86.2 }, "branches": { - "covered": 5, - "total": 15, + "covered": 1, + "total": 3, "percentage": 33.33 }, "functions": { - "covered": 3, - "total": 4, - "percentage": 75 + "covered": 1, + "total": 1, + "percentage": 100 }, "lines": { - "covered": 123, - "total": 126, - "percentage": 97.61 + "covered": 25, + "total": 29, + "percentage": 86.2 } }, - "src/vs/platform/agentHost/common/claudeProviders.ts": { + "src/vs/platform/agentHost/common/changesetUri.ts": { "statements": { - "covered": 35, - "total": 35, - "percentage": 100 + "covered": 333, + "total": 388, + "percentage": 85.82 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 - }, + "covered": 50, + "total": 62, + "percentage": 80.64 + }, + "functions": { + "covered": 19, + "total": 26, + "percentage": 73.07 + }, + "lines": { + "covered": 333, + "total": 388, + "percentage": 85.82 + } + }, + "src/vs/platform/agentHost/common/claudeModelConfig.ts": { + "statements": { + "covered": 123, + "total": 126, + "percentage": 97.61 + }, + "branches": { + "covered": 5, + "total": 15, + "percentage": 33.33 + }, + "functions": { + "covered": 3, + "total": 4, + "percentage": 75 + }, + "lines": { + "covered": 123, + "total": 126, + "percentage": 97.61 + } + }, + "src/vs/platform/agentHost/common/claudeProviders.ts": { + "statements": { + "covered": 35, + "total": 35, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, "functions": { "covered": 0, "total": 0, @@ -962,9 +1094,9 @@ }, "src/vs/platform/agentHost/common/codexAccount.ts": { "statements": { - "covered": 28, - "total": 61, - "percentage": 45.9 + "covered": 49, + "total": 122, + "percentage": 40.16 }, "branches": { "covered": 0, @@ -973,13 +1105,13 @@ }, "functions": { "covered": 0, - "total": 1, + "total": 3, "percentage": 0 }, "lines": { - "covered": 28, - "total": 61, - "percentage": 45.9 + "covered": 49, + "total": 122, + "percentage": 40.16 } }, "src/vs/platform/agentHost/common/codexSessionConfigKeys.ts": { @@ -1028,9 +1160,9 @@ }, "src/vs/platform/agentHost/common/copilotCliConfig.ts": { "statements": { - "covered": 224, - "total": 233, - "percentage": 96.13 + "covered": 278, + "total": 287, + "percentage": 96.86 }, "branches": { "covered": 7, @@ -1043,9 +1175,9 @@ "percentage": 66.66 }, "lines": { - "covered": 224, - "total": 233, - "percentage": 96.13 + "covered": 278, + "total": 287, + "percentage": 96.86 } }, "src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts": { @@ -1094,9 +1226,9 @@ }, "src/vs/platform/agentHost/common/customizationEnablement.ts": { "statements": { - "covered": 73, - "total": 120, - "percentage": 60.83 + "covered": 76, + "total": 125, + "percentage": 60.8 }, "branches": { "covered": 8, @@ -1105,13 +1237,13 @@ }, "functions": { "covered": 3, - "total": 7, - "percentage": 42.85 + "total": 8, + "percentage": 37.5 }, "lines": { - "covered": 73, - "total": 120, - "percentage": 60.83 + "covered": 76, + "total": 125, + "percentage": 60.8 } }, "src/vs/platform/agentHost/common/diffComputeService.ts": { @@ -1160,14 +1292,14 @@ }, "src/vs/platform/agentHost/common/githubEndpoints.ts": { "statements": { - "covered": 106, + "covered": 120, "total": 130, - "percentage": 81.53 + "percentage": 92.3 }, "branches": { - "covered": 4, - "total": 14, - "percentage": 28.57 + "covered": 14, + "total": 23, + "percentage": 60.86 }, "functions": { "covered": 4, @@ -1175,53 +1307,9 @@ "percentage": 100 }, "lines": { - "covered": 106, + "covered": 120, "total": 130, - "percentage": 81.53 - } - }, - "src/vs/platform/agentHost/common/githubIssueReferences.ts": { - "statements": { - "covered": 55, - "total": 74, - "percentage": 74.32 - }, - "branches": { - "covered": 1, - "total": 3, - "percentage": 33.33 - }, - "functions": { - "covered": 1, - "total": 4, - "percentage": 25 - }, - "lines": { - "covered": 55, - "total": 74, - "percentage": 74.32 - } - }, - "src/vs/platform/agentHost/common/githubPullRequestReferences.ts": { - "statements": { - "covered": 38, - "total": 64, - "percentage": 59.37 - }, - "branches": { - "covered": 2, - "total": 5, - "percentage": 40 - }, - "functions": { - "covered": 2, - "total": 4, - "percentage": 50 - }, - "lines": { - "covered": 38, - "total": 64, - "percentage": 59.37 + "percentage": 92.3 } }, "src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.ts": { @@ -1248,9 +1336,9 @@ }, "src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts": { "statements": { - "covered": 69, - "total": 138, - "percentage": 50 + "covered": 70, + "total": 142, + "percentage": 49.29 }, "branches": { "covered": 3, @@ -1263,21 +1351,21 @@ "percentage": 40 }, "lines": { - "covered": 69, - "total": 138, - "percentage": 50 + "covered": 70, + "total": 142, + "percentage": 49.29 } }, "src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts": { "statements": { - "covered": 146, - "total": 219, - "percentage": 66.66 + "covered": 149, + "total": 225, + "percentage": 66.22 }, "branches": { "covered": 2, - "total": 7, - "percentage": 28.57 + "total": 8, + "percentage": 25 }, "functions": { "covered": 2, @@ -1285,9 +1373,9 @@ "percentage": 28.57 }, "lines": { - "covered": 146, - "total": 219, - "percentage": 66.66 + "covered": 149, + "total": 225, + "percentage": 66.22 } }, "src/vs/platform/agentHost/common/meta/agentCustomizationMeta.ts": { @@ -1312,6 +1400,28 @@ "percentage": 83.01 } }, + "src/vs/platform/agentHost/common/meta/agentDevContainerWorktreeMeta.ts": { + "statements": { + "covered": 25, + "total": 34, + "percentage": 73.52 + }, + "branches": { + "covered": 3, + "total": 7, + "percentage": 42.85 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 25, + "total": 34, + "percentage": 73.52 + } + }, "src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts": { "statements": { "covered": 31, @@ -1358,24 +1468,24 @@ }, "src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts": { "statements": { - "covered": 183, - "total": 195, - "percentage": 93.84 + "covered": 199, + "total": 212, + "percentage": 93.86 }, "branches": { - "covered": 16, - "total": 29, - "percentage": 55.17 + "covered": 17, + "total": 35, + "percentage": 48.57 }, "functions": { - "covered": 7, - "total": 10, - "percentage": 70 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "lines": { - "covered": 183, - "total": 195, - "percentage": 93.84 + "covered": 199, + "total": 212, + "percentage": 93.86 } }, "src/vs/platform/agentHost/common/meta/agentFeedbackAttachments.ts": { @@ -1400,11 +1510,11 @@ "percentage": 38.75 } }, - "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "src/vs/platform/agentHost/common/meta/agentMergeMessageMeta.ts": { "statements": { - "covered": 20, - "total": 30, - "percentage": 66.66 + "covered": 23, + "total": 28, + "percentage": 82.14 }, "branches": { "covered": 0, @@ -1417,9 +1527,53 @@ "percentage": 0 }, "lines": { - "covered": 20, - "total": 30, - "percentage": 66.66 + "covered": 23, + "total": 28, + "percentage": 82.14 + } + }, + "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "statements": { + "covered": 50, + "total": 54, + "percentage": 92.59 + }, + "branches": { + "covered": 14, + "total": 19, + "percentage": 73.68 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 50, + "total": 54, + "percentage": 92.59 + } + }, + "src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts": { + "statements": { + "covered": 51, + "total": 75, + "percentage": 68 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 3, + "percentage": 0 + }, + "lines": { + "covered": 51, + "total": 75, + "percentage": 68 } }, "src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts": { @@ -1446,9 +1600,9 @@ }, "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { "statements": { - "covered": 27, - "total": 38, - "percentage": 71.05 + "covered": 52, + "total": 64, + "percentage": 81.25 }, "branches": { "covered": 2, @@ -1461,9 +1615,9 @@ "percentage": 0 }, "lines": { - "covered": 27, - "total": 38, - "percentage": 71.05 + "covered": 52, + "total": 64, + "percentage": 81.25 } }, "src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts": { @@ -1488,20 +1642,42 @@ "percentage": 84.81 } }, - "src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts": { + "src/vs/platform/agentHost/common/meta/automationMeta.ts": { "statements": { - "covered": 39, - "total": 57, - "percentage": 68.42 + "covered": 31, + "total": 35, + "percentage": 88.57 }, "branches": { - "covered": 4, - "total": 13, - "percentage": 30.76 - }, - "functions": { - "covered": 4, - "total": 6, + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "functions": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "lines": { + "covered": 31, + "total": 35, + "percentage": 88.57 + } + }, + "src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts": { + "statements": { + "covered": 39, + "total": 57, + "percentage": 68.42 + }, + "branches": { + "covered": 4, + "total": 13, + "percentage": 30.76 + }, + "functions": { + "covered": 4, + "total": 6, "percentage": 66.66 }, "lines": { @@ -1512,24 +1688,24 @@ }, "src/vs/platform/agentHost/common/openSessionLink.ts": { "statements": { - "covered": 120, - "total": 158, - "percentage": 75.94 + "covered": 132, + "total": 174, + "percentage": 75.86 }, "branches": { - "covered": 19, - "total": 30, - "percentage": 63.33 + "covered": 17, + "total": 29, + "percentage": 58.62 }, "functions": { - "covered": 4, - "total": 10, - "percentage": 40 + "covered": 5, + "total": 12, + "percentage": 41.66 }, "lines": { - "covered": 120, - "total": 158, - "percentage": 75.94 + "covered": 132, + "total": 174, + "percentage": 75.86 } }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { @@ -1600,14 +1776,14 @@ }, "src/vs/platform/agentHost/common/pendingRequestRegistry.ts": { "statements": { - "covered": 148, + "covered": 152, "total": 168, - "percentage": 88.09 + "percentage": 90.47 }, "branches": { - "covered": 19, + "covered": 20, "total": 24, - "percentage": 79.16 + "percentage": 83.33 }, "functions": { "covered": 11, @@ -1615,9 +1791,9 @@ "percentage": 91.66 }, "lines": { - "covered": 148, + "covered": 152, "total": 168, - "percentage": 88.09 + "percentage": 90.47 } }, "src/vs/platform/agentHost/common/reasoningEffort.ts": { @@ -1649,9 +1825,9 @@ "percentage": 85.18 }, "branches": { - "covered": 6, - "total": 8, - "percentage": 75 + "covered": 7, + "total": 9, + "percentage": 77.77 }, "functions": { "covered": 2, @@ -1710,8 +1886,8 @@ }, "src/vs/platform/agentHost/common/serverToolNames.ts": { "statements": { - "covered": 35, - "total": 35, + "covered": 46, + "total": 46, "percentage": 100 }, "branches": { @@ -1725,81 +1901,81 @@ "percentage": 100 }, "lines": { - "covered": 35, - "total": 35, + "covered": 46, + "total": 46, "percentage": 100 } }, "src/vs/platform/agentHost/common/sessionArtifactCollection.ts": { "statements": { - "covered": 63, - "total": 148, - "percentage": 42.56 + "covered": 149, + "total": 169, + "percentage": 88.16 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 13, + "total": 27, + "percentage": 48.14 }, "functions": { - "covered": 0, - "total": 8, - "percentage": 0 + "covered": 9, + "total": 9, + "percentage": 100 }, "lines": { - "covered": 63, - "total": 148, - "percentage": 42.56 + "covered": 149, + "total": 169, + "percentage": 88.16 } }, "src/vs/platform/agentHost/common/sessionArtifacts.ts": { "statements": { - "covered": 85, - "total": 145, - "percentage": 58.62 + "covered": 156, + "total": 181, + "percentage": 86.18 }, "branches": { - "covered": 3, - "total": 6, - "percentage": 50 + "covered": 14, + "total": 25, + "percentage": 56 }, "functions": { - "covered": 2, + "covered": 7, "total": 8, - "percentage": 25 + "percentage": 87.5 }, "lines": { - "covered": 85, - "total": 145, - "percentage": 58.62 + "covered": 156, + "total": 181, + "percentage": 86.18 } }, "src/vs/platform/agentHost/common/sessionConfigKeys.ts": { "statements": { - "covered": 60, - "total": 60, + "covered": 72, + "total": 72, "percentage": 100 }, "branches": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "functions": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "lines": { - "covered": 60, - "total": 60, + "covered": 72, + "total": 72, "percentage": 100 } }, "src/vs/platform/agentHost/common/sessionDataService.ts": { "statements": { - "covered": 484, - "total": 484, + "covered": 497, + "total": 497, "percentage": 100 }, "branches": { @@ -1813,8 +1989,8 @@ "percentage": 100 }, "lines": { - "covered": 484, - "total": 484, + "covered": 497, + "total": 497, "percentage": 100 } }, @@ -1840,11 +2016,11 @@ "percentage": 72.5 } }, - "src/vs/platform/agentHost/common/state/agentSubscription.ts": { + "src/vs/platform/agentHost/common/shellInitScript.ts": { "statements": { - "covered": 588, - "total": 1219, - "percentage": 48.23 + "covered": 53, + "total": 127, + "percentage": 41.73 }, "branches": { "covered": 2, @@ -1853,13 +2029,35 @@ }, "functions": { "covered": 1, - "total": 84, - "percentage": 1.19 + "total": 7, + "percentage": 14.28 + }, + "lines": { + "covered": 53, + "total": 127, + "percentage": 41.73 + } + }, + "src/vs/platform/agentHost/common/state/agentSubscription.ts": { + "statements": { + "covered": 628, + "total": 1286, + "percentage": 48.83 + }, + "branches": { + "covered": 10, + "total": 13, + "percentage": 76.92 + }, + "functions": { + "covered": 1, + "total": 93, + "percentage": 1.07 }, "lines": { - "covered": 588, - "total": 1219, - "percentage": 48.23 + "covered": 628, + "total": 1286, + "percentage": 48.83 } }, "src/vs/platform/agentHost/common/state/chatAttachmentContext.ts": { @@ -1884,10 +2082,32 @@ "percentage": 91.72 } }, + "src/vs/platform/agentHost/common/state/legacyProtocolCompatibility.ts": { + "statements": { + "covered": 40, + "total": 87, + "percentage": 45.97 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 4, + "percentage": 0 + }, + "lines": { + "covered": 40, + "total": 87, + "percentage": 45.97 + } + }, "src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts": { "statements": { - "covered": 419, - "total": 419, + "covered": 422, + "total": 422, "percentage": 100 }, "branches": { @@ -1901,8 +2121,8 @@ "percentage": 100 }, "lines": { - "covered": 419, - "total": 419, + "covered": 422, + "total": 422, "percentage": 100 } }, @@ -1974,24 +2194,24 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts": { "statements": { - "covered": 15, + "covered": 43, "total": 48, - "percentage": 31.25 + "percentage": 89.58 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 7, + "total": 11, + "percentage": 63.63 }, "functions": { - "covered": 0, + "covered": 1, "total": 1, - "percentage": 0 + "percentage": 100 }, "lines": { - "covered": 15, + "covered": 43, "total": 48, - "percentage": 31.25 + "percentage": 89.58 } }, "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { @@ -2018,24 +2238,24 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { "statements": { - "covered": 685, - "total": 864, - "percentage": 79.28 + "covered": 694, + "total": 906, + "percentage": 76.6 }, "branches": { "covered": 165, - "total": 232, - "percentage": 71.12 + "total": 235, + "percentage": 70.21 }, "functions": { - "covered": 15, - "total": 15, - "percentage": 100 + "covered": 16, + "total": 17, + "percentage": 94.11 }, "lines": { - "covered": 685, - "total": 864, - "percentage": 79.28 + "covered": 694, + "total": 906, + "percentage": 76.6 } }, "src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts": { @@ -2150,8 +2370,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/actions.ts": { "statements": { - "covered": 260, - "total": 260, + "covered": 263, + "total": 263, "percentage": 100 }, "branches": { @@ -2165,15 +2385,15 @@ "percentage": 100 }, "lines": { - "covered": 260, - "total": 260, + "covered": 263, + "total": 263, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/protocol/common/commands.ts": { "statements": { - "covered": 1157, - "total": 1157, + "covered": 1172, + "total": 1172, "percentage": 100 }, "branches": { @@ -2187,8 +2407,8 @@ "percentage": 100 }, "lines": { - "covered": 1157, - "total": 1157, + "covered": 1172, + "total": 1172, "percentage": 100 } }, @@ -2216,8 +2436,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/notifications.ts": { "statements": { - "covered": 67, - "total": 67, + "covered": 68, + "total": 68, "percentage": 100 }, "branches": { @@ -2231,8 +2451,8 @@ "percentage": 100 }, "lines": { - "covered": 67, - "total": 67, + "covered": 68, + "total": 68, "percentage": 100 } }, @@ -2398,8 +2618,8 @@ }, "branches": { "covered": 9, - "total": 14, - "percentage": 64.28 + "total": 13, + "percentage": 69.23 }, "functions": { "covered": 3, @@ -2414,9 +2634,9 @@ }, "src/vs/platform/agentHost/common/state/protocol/version/registry.ts": { "statements": { - "covered": 216, - "total": 222, - "percentage": 97.29 + "covered": 217, + "total": 223, + "percentage": 97.3 }, "branches": { "covered": 2, @@ -2429,9 +2649,9 @@ "percentage": 50 }, "lines": { - "covered": 216, - "total": 222, - "percentage": 97.29 + "covered": 217, + "total": 223, + "percentage": 97.3 } }, "src/vs/platform/agentHost/common/state/protocolUpgrade.ts": { @@ -2458,31 +2678,31 @@ }, "src/vs/platform/agentHost/common/state/sessionActions.ts": { "statements": { - "covered": 231, - "total": 231, + "covered": 266, + "total": 266, "percentage": 100 }, "branches": { - "covered": 6, - "total": 6, + "covered": 10, + "total": 10, "percentage": 100 }, "functions": { - "covered": 6, - "total": 6, + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 231, - "total": 231, + "covered": 266, + "total": 266, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/sessionProtocol.ts": { "statements": { - "covered": 152, - "total": 154, - "percentage": 98.7 + "covered": 158, + "total": 160, + "percentage": 98.75 }, "branches": { "covered": 5, @@ -2495,16 +2715,16 @@ "percentage": 75 }, "lines": { - "covered": 152, - "total": 154, - "percentage": 98.7 + "covered": 158, + "total": 160, + "percentage": 98.75 } }, "src/vs/platform/agentHost/common/state/sessionReducers.ts": { "statements": { - "covered": 20, - "total": 22, - "percentage": 90.9 + "covered": 30, + "total": 37, + "percentage": 81.08 }, "branches": { "covered": 0, @@ -2517,38 +2737,38 @@ "percentage": 0 }, "lines": { - "covered": 20, - "total": 22, - "percentage": 90.9 + "covered": 30, + "total": 37, + "percentage": 81.08 } }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1650, - "total": 2060, - "percentage": 80.09 + "covered": 1772, + "total": 2227, + "percentage": 79.56 }, "branches": { - "covered": 234, - "total": 339, - "percentage": 69.02 + "covered": 281, + "total": 398, + "percentage": 70.6 }, "functions": { - "covered": 70, - "total": 96, - "percentage": 72.91 + "covered": 78, + "total": 109, + "percentage": 71.55 }, "lines": { - "covered": 1650, - "total": 2060, - "percentage": 80.09 + "covered": 1772, + "total": 2227, + "percentage": 79.56 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { "statements": { - "covered": 49, - "total": 73, - "percentage": 67.12 + "covered": 61, + "total": 109, + "percentage": 55.96 }, "branches": { "covered": 4, @@ -2561,9 +2781,9 @@ "percentage": 60 }, "lines": { - "covered": 49, - "total": 73, - "percentage": 67.12 + "covered": 61, + "total": 109, + "percentage": 55.96 } }, "src/vs/platform/agentHost/common/streamingToolCallDisplay.ts": { @@ -2610,24 +2830,68 @@ "percentage": 100 } }, - "src/vs/platform/agentHost/node/activeClientState.ts": { + "src/vs/platform/agentHost/common/workspacelessScratchDir.ts": { "statements": { - "covered": 160, - "total": 195, - "percentage": 82.05 + "covered": 11, + "total": 11, + "percentage": 100 }, "branches": { - "covered": 13, - "total": 20, - "percentage": 65 + "covered": 1, + "total": 1, + "percentage": 100 }, "functions": { - "covered": 7, - "total": 16, - "percentage": 43.75 + "covered": 1, + "total": 1, + "percentage": 100 }, "lines": { - "covered": 160, + "covered": 11, + "total": 11, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/worktreePaths.ts": { + "statements": { + "covered": 35, + "total": 39, + "percentage": 89.74 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 35, + "total": 39, + "percentage": 89.74 + } + }, + "src/vs/platform/agentHost/node/activeClientState.ts": { + "statements": { + "covered": 160, + "total": 195, + "percentage": 82.05 + }, + "branches": { + "covered": 13, + "total": 20, + "percentage": 65 + }, + "functions": { + "covered": 7, + "total": 16, + "percentage": 43.75 + }, + "lines": { + "covered": 160, "total": 195, "percentage": 82.05 } @@ -2678,31 +2942,31 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 407, - "total": 442, - "percentage": 92.08 + "covered": 372, + "total": 402, + "percentage": 92.53 }, "branches": { - "covered": 46, - "total": 62, - "percentage": 74.19 + "covered": 48, + "total": 61, + "percentage": 78.68 }, "functions": { - "covered": 18, - "total": 19, - "percentage": 94.73 + "covered": 16, + "total": 17, + "percentage": 94.11 }, "lines": { - "covered": 407, - "total": 442, - "percentage": 92.08 + "covered": 372, + "total": 402, + "percentage": 92.53 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { "statements": { - "covered": 129, - "total": 180, - "percentage": 71.66 + "covered": 136, + "total": 187, + "percentage": 72.72 }, "branches": { "covered": 20, @@ -2715,9 +2979,31 @@ "percentage": 85.71 }, "lines": { - "covered": 129, - "total": 180, - "percentage": 71.66 + "covered": 136, + "total": 187, + "percentage": 72.72 + } + }, + "src/vs/platform/agentHost/node/agentHostAutomationService.ts": { + "statements": { + "covered": 566, + "total": 1099, + "percentage": 51.5 + }, + "branches": { + "covered": 106, + "total": 158, + "percentage": 67.08 + }, + "functions": { + "covered": 38, + "total": 61, + "percentage": 62.29 + }, + "lines": { + "covered": 566, + "total": 1099, + "percentage": 51.5 } }, "src/vs/platform/agentHost/node/agentHostBangCommand.ts": { @@ -2744,36 +3030,36 @@ }, "src/vs/platform/agentHost/node/agentHostBootstrap.ts": { "statements": { - "covered": 184, - "total": 191, - "percentage": 96.33 + "covered": 211, + "total": 216, + "percentage": 97.68 }, "branches": { - "covered": 6, - "total": 9, - "percentage": 66.66 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "functions": { - "covered": 2, - "total": 2, - "percentage": 100 + "covered": 5, + "total": 6, + "percentage": 83.33 }, "lines": { - "covered": 184, - "total": 191, - "percentage": 96.33 + "covered": 211, + "total": 216, + "percentage": 97.68 } }, "src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts": { "statements": { - "covered": 343, - "total": 367, - "percentage": 93.46 + "covered": 336, + "total": 360, + "percentage": 93.33 }, "branches": { - "covered": 52, - "total": 58, - "percentage": 89.65 + "covered": 53, + "total": 59, + "percentage": 89.83 }, "functions": { "covered": 15, @@ -2781,21 +3067,21 @@ "percentage": 93.75 }, "lines": { - "covered": 343, - "total": 367, - "percentage": 93.46 + "covered": 336, + "total": 360, + "percentage": 93.33 } }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 378, + "covered": 388, "total": 440, - "percentage": 85.9 + "percentage": 88.18 }, "branches": { - "covered": 58, - "total": 81, - "percentage": 71.6 + "covered": 68, + "total": 89, + "percentage": 76.4 }, "functions": { "covered": 29, @@ -2803,53 +3089,53 @@ "percentage": 96.66 }, "lines": { - "covered": 378, + "covered": 388, "total": 440, - "percentage": 85.9 + "percentage": 88.18 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 265, - "total": 296, - "percentage": 89.52 + "covered": 284, + "total": 317, + "percentage": 89.58 }, "branches": { - "covered": 53, - "total": 69, - "percentage": 76.81 + "covered": 60, + "total": 77, + "percentage": 77.92 }, "functions": { - "covered": 13, - "total": 13, + "covered": 14, + "total": 14, "percentage": 100 }, "lines": { - "covered": 265, - "total": 296, - "percentage": 89.52 + "covered": 284, + "total": 317, + "percentage": 89.58 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1241, - "total": 1653, - "percentage": 75.07 + "covered": 1245, + "total": 1632, + "percentage": 76.28 }, "branches": { - "covered": 189, - "total": 260, + "covered": 197, + "total": 271, "percentage": 72.69 }, "functions": { - "covered": 56, + "covered": 57, "total": 70, - "percentage": 80 + "percentage": 81.42 }, "lines": { - "covered": 1241, - "total": 1653, - "percentage": 75.07 + "covered": 1245, + "total": 1632, + "percentage": 76.28 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2876,14 +3162,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts": { "statements": { - "covered": 42, - "total": 44, - "percentage": 95.45 + "covered": 52, + "total": 55, + "percentage": 94.54 }, "branches": { - "covered": 10, - "total": 11, - "percentage": 90.9 + "covered": 12, + "total": 13, + "percentage": 92.3 }, "functions": { "covered": 5, @@ -2891,9 +3177,9 @@ "percentage": 100 }, "lines": { - "covered": 42, - "total": 44, - "percentage": 95.45 + "covered": 52, + "total": 55, + "percentage": 94.54 } }, "src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts": { @@ -2940,16 +3226,38 @@ "percentage": 94.44 } }, + "src/vs/platform/agentHost/node/agentHostChatContributionsService.ts": { + "statements": { + "covered": 264, + "total": 294, + "percentage": 89.79 + }, + "branches": { + "covered": 64, + "total": 81, + "percentage": 79.01 + }, + "functions": { + "covered": 22, + "total": 23, + "percentage": 95.65 + }, + "lines": { + "covered": 264, + "total": 294, + "percentage": 89.79 + } + }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { "statements": { - "covered": 360, + "covered": 368, "total": 508, - "percentage": 70.86 + "percentage": 72.44 }, "branches": { - "covered": 77, - "total": 107, - "percentage": 71.96 + "covered": 98, + "total": 124, + "percentage": 79.03 }, "functions": { "covered": 17, @@ -2957,9 +3265,9 @@ "percentage": 80.95 }, "lines": { - "covered": 360, + "covered": 368, "total": 508, - "percentage": 70.86 + "percentage": 72.44 } }, "src/vs/platform/agentHost/node/agentHostClientConnectionService.ts": { @@ -3008,14 +3316,14 @@ }, "src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts": { "statements": { - "covered": 58, - "total": 58, - "percentage": 100 + "covered": 60, + "total": 62, + "percentage": 96.77 }, "branches": { - "covered": 13, - "total": 14, - "percentage": 92.85 + "covered": 12, + "total": 16, + "percentage": 75 }, "functions": { "covered": 6, @@ -3023,9 +3331,9 @@ "percentage": 100 }, "lines": { - "covered": 58, - "total": 58, - "percentage": 100 + "covered": 60, + "total": 62, + "percentage": 96.77 } }, "src/vs/platform/agentHost/node/agentHostCompletions.ts": { @@ -3052,9 +3360,9 @@ }, "src/vs/platform/agentHost/node/agentHostContributions.ts": { "statements": { - "covered": 47, - "total": 50, - "percentage": 94 + "covered": 50, + "total": 53, + "percentage": 94.33 }, "branches": { "covered": 2, @@ -3067,53 +3375,53 @@ "percentage": 100 }, "lines": { - "covered": 47, - "total": 50, - "percentage": 94 + "covered": 50, + "total": 53, + "percentage": 94.33 } }, "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { "statements": { - "covered": 580, - "total": 734, - "percentage": 79.01 + "covered": 566, + "total": 720, + "percentage": 78.61 }, "branches": { - "covered": 107, - "total": 148, - "percentage": 72.29 + "covered": 106, + "total": 147, + "percentage": 72.1 }, "functions": { - "covered": 43, - "total": 48, - "percentage": 89.58 + "covered": 41, + "total": 46, + "percentage": 89.13 }, "lines": { - "covered": 580, - "total": 734, - "percentage": 79.01 + "covered": 566, + "total": 720, + "percentage": 78.61 } }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 327, - "total": 426, - "percentage": 76.76 + "covered": 379, + "total": 481, + "percentage": 78.79 }, "branches": { - "covered": 47, - "total": 63, - "percentage": 74.6 + "covered": 53, + "total": 70, + "percentage": 75.71 }, "functions": { - "covered": 24, - "total": 32, - "percentage": 75 + "covered": 27, + "total": 34, + "percentage": 79.41 }, "lines": { - "covered": 327, - "total": 426, - "percentage": 76.76 + "covered": 379, + "total": 481, + "percentage": 78.79 } }, "src/vs/platform/agentHost/node/agentHostDebugLogs.ts": { @@ -3123,9 +3431,9 @@ "percentage": 87.38 }, "branches": { - "covered": 28, - "total": 40, - "percentage": 70 + "covered": 30, + "total": 42, + "percentage": 71.42 }, "functions": { "covered": 9, @@ -3228,14 +3536,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileMonitorService.ts": { "statements": { - "covered": 169, + "covered": 166, "total": 185, - "percentage": 91.35 + "percentage": 89.72 }, "branches": { - "covered": 28, - "total": 37, - "percentage": 75.67 + "covered": 23, + "total": 35, + "percentage": 65.71 }, "functions": { "covered": 14, @@ -3243,21 +3551,21 @@ "percentage": 100 }, "lines": { - "covered": 169, + "covered": 166, "total": 185, - "percentage": 91.35 + "percentage": 89.72 } }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { "statements": { - "covered": 121, + "covered": 125, "total": 127, - "percentage": 95.27 + "percentage": 98.42 }, "branches": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 13, + "total": 13, + "percentage": 100 }, "functions": { "covered": 7, @@ -3265,9 +3573,9 @@ "percentage": 87.5 }, "lines": { - "covered": 121, + "covered": 125, "total": 127, - "percentage": 95.27 + "percentage": 98.42 } }, "src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts": { @@ -3294,46 +3602,46 @@ }, "src/vs/platform/agentHost/node/agentHostGitService.ts": { "statements": { - "covered": 1262, - "total": 1736, - "percentage": 72.69 + "covered": 1274, + "total": 1744, + "percentage": 73.05 }, "branches": { - "covered": 273, - "total": 385, - "percentage": 70.9 + "covered": 286, + "total": 392, + "percentage": 72.95 }, "functions": { - "covered": 63, - "total": 80, - "percentage": 78.75 + "covered": 64, + "total": 81, + "percentage": 79.01 }, "lines": { - "covered": 1262, - "total": 1736, - "percentage": 72.69 + "covered": 1274, + "total": 1744, + "percentage": 73.05 } }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 234, - "total": 426, - "percentage": 54.92 + "covered": 263, + "total": 394, + "percentage": 66.75 }, "branches": { - "covered": 62, - "total": 87, - "percentage": 71.26 + "covered": 70, + "total": 90, + "percentage": 77.77 }, "functions": { "covered": 9, - "total": 14, - "percentage": 64.28 + "total": 13, + "percentage": 69.23 }, "lines": { - "covered": 234, - "total": 426, - "percentage": 54.92 + "covered": 263, + "total": 394, + "percentage": 66.75 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -3365,9 +3673,9 @@ "percentage": 89.02 }, "branches": { - "covered": 47, - "total": 57, - "percentage": 82.45 + "covered": 49, + "total": 59, + "percentage": 83.05 }, "functions": { "covered": 13, @@ -3382,24 +3690,24 @@ }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { "statements": { - "covered": 130, - "total": 161, - "percentage": 80.74 + "covered": 161, + "total": 193, + "percentage": 83.41 }, "branches": { - "covered": 15, - "total": 24, - "percentage": 62.5 + "covered": 19, + "total": 29, + "percentage": 65.51 }, "functions": { - "covered": 9, - "total": 11, - "percentage": 81.81 - }, - "lines": { - "covered": 130, - "total": 161, - "percentage": 80.74 + "covered": 10, + "total": 12, + "percentage": 83.33 + }, + "lines": { + "covered": 161, + "total": 193, + "percentage": 83.41 } }, "src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts": { @@ -3534,6 +3842,28 @@ "percentage": 96.66 } }, + "src/vs/platform/agentHost/node/agentHostProviderService.ts": { + "statements": { + "covered": 191, + "total": 233, + "percentage": 81.97 + }, + "branches": { + "covered": 30, + "total": 45, + "percentage": 66.66 + }, + "functions": { + "covered": 12, + "total": 15, + "percentage": 80 + }, + "lines": { + "covered": 191, + "total": 233, + "percentage": 81.97 + } + }, "src/vs/platform/agentHost/node/agentHostProxyResolver.ts": { "statements": { "covered": 158, @@ -3556,11 +3886,33 @@ "percentage": 73.83 } }, + "src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts": { + "statements": { + "covered": 76, + "total": 197, + "percentage": 38.57 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 8, + "percentage": 12.5 + }, + "lines": { + "covered": 76, + "total": 197, + "percentage": 38.57 + } + }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts": { "statements": { - "covered": 127, - "total": 497, - "percentage": 25.55 + "covered": 138, + "total": 560, + "percentage": 24.64 }, "branches": { "covered": 1, @@ -3569,35 +3921,57 @@ }, "functions": { "covered": 1, - "total": 15, - "percentage": 6.66 + "total": 16, + "percentage": 6.25 }, "lines": { - "covered": 127, - "total": 497, - "percentage": 25.55 + "covered": 138, + "total": 560, + "percentage": 24.64 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts": { "statements": { - "covered": 66, - "total": 117, - "percentage": 56.41 + "covered": 221, + "total": 301, + "percentage": 73.42 }, "branches": { - "covered": 14, - "total": 18, - "percentage": 77.77 + "covered": 41, + "total": 45, + "percentage": 91.11 }, "functions": { - "covered": 4, - "total": 8, - "percentage": 50 + "covered": 7, + "total": 13, + "percentage": 53.84 + }, + "lines": { + "covered": 221, + "total": 301, + "percentage": 73.42 + } + }, + "src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts": { + "statements": { + "covered": 257, + "total": 531, + "percentage": 48.39 + }, + "branches": { + "covered": 28, + "total": 41, + "percentage": 68.29 + }, + "functions": { + "covered": 7, + "total": 25, + "percentage": 28 }, "lines": { - "covered": 66, - "total": 117, - "percentage": 56.41 + "covered": 257, + "total": 531, + "percentage": 48.39 } }, "src/vs/platform/agentHost/node/agentHostRenameCommand.ts": { @@ -3669,8 +4043,8 @@ "src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts": { "statements": { "covered": 174, - "total": 313, - "percentage": 55.59 + "total": 317, + "percentage": 54.88 }, "branches": { "covered": 2, @@ -3684,20 +4058,20 @@ }, "lines": { "covered": 174, - "total": 313, - "percentage": 55.59 + "total": 317, + "percentage": 54.88 } }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { "statements": { - "covered": 209, + "covered": 211, "total": 264, - "percentage": 79.16 + "percentage": 79.92 }, "branches": { - "covered": 35, - "total": 52, - "percentage": 67.3 + "covered": 38, + "total": 54, + "percentage": 70.37 }, "functions": { "covered": 9, @@ -3705,16 +4079,16 @@ "percentage": 69.23 }, "lines": { - "covered": 209, + "covered": 211, "total": 264, - "percentage": 79.16 + "percentage": 79.92 } }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { "statements": { - "covered": 351, - "total": 401, - "percentage": 87.53 + "covered": 354, + "total": 402, + "percentage": 88.05 }, "branches": { "covered": 23, @@ -3727,31 +4101,53 @@ "percentage": 85.71 }, "lines": { - "covered": 351, - "total": 401, - "percentage": 87.53 + "covered": 354, + "total": 402, + "percentage": 88.05 } }, "src/vs/platform/agentHost/node/agentHostServices.ts": { "statements": { - "covered": 161, - "total": 165, - "percentage": 97.57 + "covered": 130, + "total": 130, + "percentage": 100 }, "branches": { - "covered": 10, - "total": 13, - "percentage": 76.92 + "covered": 2, + "total": 3, + "percentage": 66.66 }, "functions": { - "covered": 7, - "total": 7, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 161, - "total": 165, - "percentage": 97.57 + "covered": 130, + "total": 130, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts": { + "statements": { + "covered": 280, + "total": 307, + "percentage": 91.2 + }, + "branches": { + "covered": 56, + "total": 66, + "percentage": 84.84 + }, + "functions": { + "covered": 19, + "total": 19, + "percentage": 100 + }, + "lines": { + "covered": 280, + "total": 307, + "percentage": 91.2 } }, "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { @@ -3778,24 +4174,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 638, - "total": 894, - "percentage": 71.36 + "covered": 679, + "total": 914, + "percentage": 74.28 }, "branches": { - "covered": 106, - "total": 155, - "percentage": 68.38 + "covered": 123, + "total": 172, + "percentage": 71.51 }, "functions": { - "covered": 31, + "covered": 32, "total": 46, - "percentage": 67.39 + "percentage": 69.56 }, "lines": { - "covered": 638, - "total": 894, - "percentage": 71.36 + "covered": 679, + "total": 914, + "percentage": 74.28 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts": { @@ -3842,6 +4238,28 @@ "percentage": 85.71 } }, + "src/vs/platform/agentHost/node/agentHostShutdown.ts": { + "statements": { + "covered": 22, + "total": 25, + "percentage": 88 + }, + "branches": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 22, + "total": 25, + "percentage": 88 + } + }, "src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts": { "statements": { "covered": 68, @@ -3888,46 +4306,68 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1730, - "total": 1927, - "percentage": 89.77 + "covered": 1830, + "total": 2059, + "percentage": 88.87 }, "branches": { - "covered": 276, - "total": 334, - "percentage": 82.63 + "covered": 305, + "total": 368, + "percentage": 82.88 }, "functions": { - "covered": 77, - "total": 89, - "percentage": 86.51 + "covered": 83, + "total": 96, + "percentage": 86.45 }, "lines": { - "covered": 1730, - "total": 1927, - "percentage": 89.77 + "covered": 1830, + "total": 2059, + "percentage": 88.87 } }, "src/vs/platform/agentHost/node/agentHostStorageService.ts": { "statements": { - "covered": 110, - "total": 121, - "percentage": 90.9 + "covered": 141, + "total": 169, + "percentage": 83.43 }, "branches": { - "covered": 17, - "total": 23, - "percentage": 73.91 + "covered": 22, + "total": 29, + "percentage": 75.86 }, "functions": { - "covered": 10, - "total": 10, + "covered": 13, + "total": 13, "percentage": 100 }, "lines": { - "covered": 110, - "total": 121, - "percentage": 90.9 + "covered": 141, + "total": 169, + "percentage": 83.43 + } + }, + "src/vs/platform/agentHost/node/agentHostSubscriptionService.ts": { + "statements": { + "covered": 52, + "total": 56, + "percentage": 92.85 + }, + "branches": { + "covered": 13, + "total": 13, + "percentage": 100 + }, + "functions": { + "covered": 4, + "total": 6, + "percentage": 66.66 + }, + "lines": { + "covered": 52, + "total": 56, + "percentage": 92.85 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts": { @@ -3976,46 +4416,46 @@ }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1176, - "total": 1338, - "percentage": 87.89 + "covered": 1264, + "total": 1428, + "percentage": 88.51 }, "branches": { - "covered": 66, - "total": 96, - "percentage": 68.75 + "covered": 68, + "total": 100, + "percentage": 68 }, "functions": { - "covered": 16, - "total": 23, - "percentage": 69.56 + "covered": 17, + "total": 24, + "percentage": 70.83 }, "lines": { - "covered": 1176, - "total": 1338, - "percentage": 87.89 + "covered": 1264, + "total": 1428, + "percentage": 88.51 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { - "covered": 198, - "total": 287, - "percentage": 68.98 + "covered": 212, + "total": 295, + "percentage": 71.86 }, "branches": { - "covered": 18, - "total": 47, - "percentage": 38.29 + "covered": 22, + "total": 53, + "percentage": 41.5 }, "functions": { - "covered": 15, + "covered": 17, "total": 30, - "percentage": 50 + "percentage": 56.66 }, "lines": { - "covered": 198, - "total": 287, - "percentage": 68.98 + "covered": 212, + "total": 295, + "percentage": 71.86 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { @@ -4025,9 +4465,9 @@ "percentage": 90.78 }, "branches": { - "covered": 120, - "total": 151, - "percentage": 79.47 + "covered": 121, + "total": 152, + "percentage": 79.6 }, "functions": { "covered": 35, @@ -4042,65 +4482,109 @@ }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { "statements": { - "covered": 266, - "total": 309, - "percentage": 86.08 + "covered": 241, + "total": 283, + "percentage": 85.15 }, "branches": { - "covered": 55, - "total": 62, - "percentage": 88.7 + "covered": 45, + "total": 51, + "percentage": 88.23 }, "functions": { - "covered": 15, - "total": 16, - "percentage": 93.75 + "covered": 13, + "total": 13, + "percentage": 100 }, "lines": { - "covered": 266, - "total": 309, - "percentage": 86.08 + "covered": 241, + "total": 283, + "percentage": 85.15 } }, - "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { + "src/vs/platform/agentHost/node/agentHostTurnStarter.ts": { "statements": { - "covered": 474, - "total": 606, - "percentage": 78.21 + "covered": 90, + "total": 123, + "percentage": 73.17 }, "branches": { - "covered": 48, - "total": 67, - "percentage": 71.64 + "covered": 9, + "total": 12, + "percentage": 75 }, "functions": { - "covered": 25, - "total": 31, - "percentage": 80.64 + "covered": 1, + "total": 1, + "percentage": 100 }, "lines": { - "covered": 474, - "total": 606, - "percentage": 78.21 + "covered": 90, + "total": 123, + "percentage": 73.17 } }, - "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { + "src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts": { "statements": { - "covered": 52, - "total": 89, - "percentage": 58.42 + "covered": 56, + "total": 57, + "percentage": 98.24 }, "branches": { - "covered": 1, - "total": 3, - "percentage": 33.33 + "covered": 22, + "total": 25, + "percentage": 88 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 - }, - "lines": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 56, + "total": 57, + "percentage": 98.24 + } + }, + "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { + "statements": { + "covered": 490, + "total": 622, + "percentage": 78.77 + }, + "branches": { + "covered": 49, + "total": 68, + "percentage": 72.05 + }, + "functions": { + "covered": 25, + "total": 31, + "percentage": 80.64 + }, + "lines": { + "covered": 490, + "total": 622, + "percentage": 78.77 + } + }, + "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { + "statements": { + "covered": 52, + "total": 89, + "percentage": 58.42 + }, + "branches": { + "covered": 1, + "total": 3, + "percentage": 33.33 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { "covered": 52, "total": 89, "percentage": 58.42 @@ -4130,24 +4614,24 @@ }, "src/vs/platform/agentHost/node/agentMergeController.ts": { "statements": { - "covered": 329, - "total": 1020, - "percentage": 32.25 + "covered": 410, + "total": 1193, + "percentage": 34.36 }, "branches": { - "covered": 23, - "total": 49, - "percentage": 46.93 + "covered": 32, + "total": 69, + "percentage": 46.37 }, "functions": { - "covered": 13, - "total": 49, - "percentage": 26.53 + "covered": 14, + "total": 51, + "percentage": 27.45 }, "lines": { - "covered": 329, - "total": 1020, - "percentage": 32.25 + "covered": 410, + "total": 1193, + "percentage": 34.36 } }, "src/vs/platform/agentHost/node/agentMergeTools.ts": { @@ -4196,24 +4680,24 @@ }, "src/vs/platform/agentHost/node/agentPeerChats.ts": { "statements": { - "covered": 204, - "total": 390, - "percentage": 52.3 + "covered": 128, + "total": 194, + "percentage": 65.97 }, "branches": { - "covered": 17, - "total": 50, - "percentage": 34 + "covered": 4, + "total": 15, + "percentage": 26.66 }, "functions": { - "covered": 7, - "total": 27, - "percentage": 25.92 + "covered": 4, + "total": 19, + "percentage": 21.05 }, "lines": { - "covered": 204, - "total": 390, - "percentage": 52.3 + "covered": 128, + "total": 194, + "percentage": 65.97 } }, "src/vs/platform/agentHost/node/agentPluginManager.ts": { @@ -4240,9 +4724,9 @@ }, "src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts": { "statements": { - "covered": 85, - "total": 121, - "percentage": 70.24 + "covered": 86, + "total": 122, + "percentage": 70.49 }, "branches": { "covered": 0, @@ -4255,9 +4739,9 @@ "percentage": 0 }, "lines": { - "covered": 85, - "total": 121, - "percentage": 70.24 + "covered": 86, + "total": 122, + "percentage": 70.49 } }, "src/vs/platform/agentHost/node/agentSdkDownloader.ts": { @@ -4306,112 +4790,134 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 5038, - "total": 6935, - "percentage": 72.64 + "covered": 5412, + "total": 7273, + "percentage": 74.41 }, "branches": { - "covered": 890, - "total": 1358, - "percentage": 65.53 + "covered": 1033, + "total": 1521, + "percentage": 67.91 }, "functions": { - "covered": 213, - "total": 283, - "percentage": 75.26 + "covered": 250, + "total": 318, + "percentage": 78.61 }, "lines": { - "covered": 5038, - "total": 6935, - "percentage": 72.64 + "covered": 5412, + "total": 7273, + "percentage": 74.41 } }, "src/vs/platform/agentHost/node/agentServiceComposition.ts": { "statements": { - "covered": 206, - "total": 224, - "percentage": 91.96 + "covered": 180, + "total": 196, + "percentage": 91.83 }, "branches": { - "covered": 12, - "total": 20, - "percentage": 60 + "covered": 7, + "total": 13, + "percentage": 53.84 }, "functions": { - "covered": 10, - "total": 16, - "percentage": 62.5 + "covered": 5, + "total": 9, + "percentage": 55.55 }, "lines": { - "covered": 206, - "total": 224, - "percentage": 91.96 + "covered": 180, + "total": 196, + "percentage": 91.83 } }, "src/vs/platform/agentHost/node/agentServiceFoundation.ts": { "statements": { - "covered": 139, - "total": 152, - "percentage": 91.44 + "covered": 142, + "total": 155, + "percentage": 91.61 }, "branches": { - "covered": 17, - "total": 21, - "percentage": 80.95 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "functions": { - "covered": 17, - "total": 24, - "percentage": 70.83 + "covered": 19, + "total": 27, + "percentage": 70.37 }, "lines": { - "covered": 139, - "total": 152, - "percentage": 91.44 + "covered": 142, + "total": 155, + "percentage": 91.61 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { "statements": { - "covered": 166, - "total": 212, - "percentage": 78.3 + "covered": 210, + "total": 236, + "percentage": 88.98 }, "branches": { - "covered": 15, + "covered": 23, + "total": 29, + "percentage": 79.31 + }, + "functions": { + "covered": 14, "total": 19, - "percentage": 78.94 + "percentage": 73.68 + }, + "lines": { + "covered": 210, + "total": 236, + "percentage": 88.98 + } + }, + "src/vs/platform/agentHost/node/agentSessionResidency.ts": { + "statements": { + "covered": 240, + "total": 277, + "percentage": 86.64 + }, + "branches": { + "covered": 69, + "total": 80, + "percentage": 86.25 }, "functions": { - "covered": 10, - "total": 16, - "percentage": 62.5 + "covered": 12, + "total": 13, + "percentage": 92.3 }, "lines": { - "covered": 166, - "total": 212, - "percentage": 78.3 + "covered": 240, + "total": 277, + "percentage": 86.64 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 2104, - "total": 2450, - "percentage": 85.87 + "covered": 1607, + "total": 1909, + "percentage": 84.18 }, "branches": { - "covered": 419, - "total": 552, - "percentage": 75.9 + "covered": 295, + "total": 400, + "percentage": 73.75 }, "functions": { - "covered": 69, - "total": 77, - "percentage": 89.61 + "covered": 48, + "total": 54, + "percentage": 88.88 }, "lines": { - "covered": 2104, - "total": 2450, - "percentage": 85.87 + "covered": 1607, + "total": 1909, + "percentage": 84.18 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -4436,6 +4942,28 @@ "percentage": 92.59 } }, + "src/vs/platform/agentHost/node/automationCron.ts": { + "statements": { + "covered": 67, + "total": 239, + "percentage": 28.03 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 11, + "percentage": 0 + }, + "lines": { + "covered": 67, + "total": 239, + "percentage": 28.03 + } + }, "src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts": { "statements": { "covered": 117, @@ -4443,19 +4971,437 @@ "percentage": 57.35 }, "branches": { - "covered": 1, - "total": 1, + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 13, + "percentage": 7.69 + }, + "lines": { + "covered": 117, + "total": 204, + "percentage": 57.35 + } + }, + "src/vs/platform/agentHost/node/chatContributions/artifactTools/artifactToolsContribution.ts": { + "statements": { + "covered": 30, + "total": 30, + "percentage": 100 + }, + "branches": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 30, + "total": 30, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts": { + "statements": { + "covered": 49, + "total": 49, + "percentage": 100 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 49, + "total": 49, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts": { + "statements": { + "covered": 71, + "total": 80, + "percentage": 88.75 + }, + "branches": { + "covered": 8, + "total": 13, + "percentage": 61.53 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 71, + "total": 80, + "percentage": 88.75 + } + }, + "src/vs/platform/agentHost/node/chatContributions/chatSurface/chatSurfaceContribution.ts": { + "statements": { + "covered": 31, + "total": 33, + "percentage": 93.93 + }, + "branches": { + "covered": 2, + "total": 7, + "percentage": 28.57 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 31, + "total": 33, + "percentage": 93.93 + } + }, + "src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts": { + "statements": { + "covered": 51, + "total": 59, + "percentage": 86.44 + }, + "branches": { + "covered": 13, + "total": 14, + "percentage": 92.85 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 51, + "total": 59, + "percentage": 86.44 + } + }, + "src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts": { + "statements": { + "covered": 32, + "total": 32, + "percentage": 100 + }, + "branches": { + "covered": 3, + "total": 4, + "percentage": 75 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 32, + "total": 32, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/localCommand/localCommandContribution.ts": { + "statements": { + "covered": 42, + "total": 42, + "percentage": 100 + }, + "branches": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 42, + "total": 42, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts": { + "statements": { + "covered": 40, + "total": 42, + "percentage": 95.23 + }, + "branches": { + "covered": 8, + "total": 10, + "percentage": 80 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 40, + "total": 42, + "percentage": 95.23 + } + }, + "src/vs/platform/agentHost/node/chatContributions/markdownPlanRichLinks/markdownPlanRichLinksContribution.ts": { + "statements": { + "covered": 31, + "total": 46, + "percentage": 67.39 + }, + "branches": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 31, + "total": 46, + "percentage": 67.39 + } + }, + "src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts": { + "statements": { + "covered": 147, + "total": 165, + "percentage": 89.09 + }, + "branches": { + "covered": 34, + "total": 43, + "percentage": 79.06 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 147, + "total": 165, + "percentage": 89.09 + } + }, + "src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts": { + "statements": { + "covered": 137, + "total": 166, + "percentage": 82.53 + }, + "branches": { + "covered": 25, + "total": 35, + "percentage": 71.42 + }, + "functions": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "lines": { + "covered": 137, + "total": 166, + "percentage": 82.53 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts": { + "statements": { + "covered": 53, + "total": 53, + "percentage": 100 + }, + "branches": { + "covered": 11, + "total": 11, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 53, + "total": 53, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts": { + "statements": { + "covered": 206, + "total": 220, + "percentage": 93.63 + }, + "branches": { + "covered": 70, + "total": 76, + "percentage": 92.1 + }, + "functions": { + "covered": 13, + "total": 13, + "percentage": 100 + }, + "lines": { + "covered": 206, + "total": 220, + "percentage": 93.63 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts": { + "statements": { + "covered": 87, + "total": 98, + "percentage": 88.77 + }, + "branches": { + "covered": 19, + "total": 24, + "percentage": 79.16 + }, + "functions": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "lines": { + "covered": 87, + "total": 98, + "percentage": 88.77 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts": { + "statements": { + "covered": 52, + "total": 159, + "percentage": 32.7 + }, + "branches": { + "covered": 2, + "total": 6, + "percentage": 33.33 + }, + "functions": { + "covered": 2, + "total": 9, + "percentage": 22.22 + }, + "lines": { + "covered": 52, + "total": 159, + "percentage": 32.7 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts": { + "statements": { + "covered": 70, + "total": 83, + "percentage": 84.33 + }, + "branches": { + "covered": 18, + "total": 26, + "percentage": 69.23 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 70, + "total": 83, + "percentage": 84.33 + } + }, + "src/vs/platform/agentHost/node/chatContributions/turnAdmission/turnAdmissionContribution.ts": { + "statements": { + "covered": 42, + "total": 48, + "percentage": 87.5 + }, + "branches": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 42, + "total": 48, + "percentage": 87.5 + } + }, + "src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts": { + "statements": { + "covered": 85, + "total": 97, + "percentage": 87.62 + }, + "branches": { + "covered": 16, + "total": 22, + "percentage": 72.72 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 85, + "total": 97, + "percentage": 87.62 + } + }, + "src/vs/platform/agentHost/node/chatContributions/worktreeAnnouncement/worktreeAnnouncementContribution.ts": { + "statements": { + "covered": 31, + "total": 31, + "percentage": 100 + }, + "branches": { + "covered": 5, + "total": 5, "percentage": 100 }, "functions": { - "covered": 1, - "total": 13, - "percentage": 7.69 + "covered": 2, + "total": 2, + "percentage": 100 }, "lines": { - "covered": 117, - "total": 204, - "percentage": 57.35 + "covered": 31, + "total": 31, + "percentage": 100 } }, "src/vs/platform/agentHost/node/claude/anthropicBetas.ts": { @@ -4504,75 +5450,75 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2216, - "total": 2713, - "percentage": 81.68 + "covered": 2235, + "total": 2667, + "percentage": 83.8 }, "branches": { - "covered": 223, - "total": 334, - "percentage": 66.76 + "covered": 210, + "total": 329, + "percentage": 63.82 }, "functions": { "covered": 99, - "total": 128, - "percentage": 77.34 + "total": 127, + "percentage": 77.95 }, "lines": { - "covered": 2216, - "total": 2713, - "percentage": 81.68 + "covered": 2235, + "total": 2667, + "percentage": 83.8 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { "statements": { - "covered": 278, + "covered": 281, "total": 324, - "percentage": 85.8 + "percentage": 86.72 }, "branches": { - "covered": 14, - "total": 19, - "percentage": 73.68 + "covered": 15, + "total": 20, + "percentage": 75 }, "functions": { - "covered": 12, + "covered": 13, "total": 16, - "percentage": 75 + "percentage": 81.25 }, "lines": { - "covered": 278, + "covered": 281, "total": 324, - "percentage": 85.8 + "percentage": 86.72 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 1270, - "total": 1631, - "percentage": 77.86 + "covered": 1290, + "total": 1658, + "percentage": 77.8 }, "branches": { - "covered": 82, - "total": 137, - "percentage": 59.85 + "covered": 91, + "total": 146, + "percentage": 62.32 }, "functions": { - "covered": 42, - "total": 69, - "percentage": 60.86 + "covered": 45, + "total": 71, + "percentage": 63.38 }, "lines": { - "covered": 1270, - "total": 1631, - "percentage": 77.86 + "covered": 1290, + "total": 1658, + "percentage": 77.8 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { "statements": { - "covered": 257, - "total": 301, - "percentage": 85.38 + "covered": 258, + "total": 302, + "percentage": 85.43 }, "branches": { "covered": 22, @@ -4585,9 +5531,9 @@ "percentage": 71.42 }, "lines": { - "covered": 257, - "total": 301, - "percentage": 85.38 + "covered": 258, + "total": 302, + "percentage": 85.43 } }, "src/vs/platform/agentHost/node/claude/claudeElicitation.ts": { @@ -4614,9 +5560,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeElicitationBridge.ts": { "statements": { - "covered": 36, - "total": 77, - "percentage": 46.75 + "covered": 35, + "total": 76, + "percentage": 46.05 }, "branches": { "covered": 0, @@ -4629,9 +5575,9 @@ "percentage": 0 }, "lines": { - "covered": 36, - "total": 77, - "percentage": 46.75 + "covered": 35, + "total": 76, + "percentage": 46.05 } }, "src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts": { @@ -4751,9 +5697,9 @@ "percentage": 83.83 }, "branches": { - "covered": 37, - "total": 48, - "percentage": 77.08 + "covered": 36, + "total": 47, + "percentage": 76.59 }, "functions": { "covered": 11, @@ -4878,14 +5824,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts": { "statements": { - "covered": 504, + "covered": 520, "total": 652, - "percentage": 77.3 + "percentage": 79.75 }, "branches": { - "covered": 61, - "total": 116, - "percentage": 52.58 + "covered": 63, + "total": 120, + "percentage": 52.5 }, "functions": { "covered": 18, @@ -4893,9 +5839,9 @@ "percentage": 78.26 }, "lines": { - "covered": 504, + "covered": 520, "total": 652, - "percentage": 77.3 + "percentage": 79.75 } }, "src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts": { @@ -4922,24 +5868,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts": { "statements": { - "covered": 346, - "total": 403, - "percentage": 85.85 + "covered": 355, + "total": 412, + "percentage": 86.16 }, "branches": { - "covered": 18, - "total": 53, - "percentage": 33.96 + "covered": 19, + "total": 54, + "percentage": 35.18 }, "functions": { - "covered": 7, - "total": 9, - "percentage": 77.77 + "covered": 8, + "total": 10, + "percentage": 80 }, "lines": { - "covered": 346, - "total": 403, - "percentage": 85.85 + "covered": 355, + "total": 412, + "percentage": 86.16 } }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { @@ -4949,9 +5895,9 @@ "percentage": 77.53 }, "branches": { - "covered": 41, - "total": 65, - "percentage": 63.07 + "covered": 45, + "total": 69, + "percentage": 65.21 }, "functions": { "covered": 21, @@ -4988,24 +5934,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts": { "statements": { - "covered": 225, + "covered": 205, "total": 261, - "percentage": 86.2 + "percentage": 78.54 }, "branches": { - "covered": 26, - "total": 41, - "percentage": 63.41 + "covered": 22, + "total": 37, + "percentage": 59.45 }, "functions": { - "covered": 9, + "covered": 8, "total": 9, - "percentage": 100 + "percentage": 88.88 }, "lines": { - "covered": 225, + "covered": 205, "total": 261, - "percentage": 86.2 + "percentage": 78.54 } }, "src/vs/platform/agentHost/node/claude/claudeSessionPermissionMode.ts": { @@ -5037,9 +5983,9 @@ "percentage": 93.72 }, "branches": { - "covered": 42, - "total": 51, - "percentage": 82.35 + "covered": 40, + "total": 49, + "percentage": 81.63 }, "functions": { "covered": 17, @@ -5142,14 +6088,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts": { "statements": { - "covered": 469, + "covered": 480, "total": 590, - "percentage": 79.49 + "percentage": 81.35 }, "branches": { - "covered": 73, - "total": 131, - "percentage": 55.72 + "covered": 77, + "total": 132, + "percentage": 58.33 }, "functions": { "covered": 15, @@ -5157,9 +6103,9 @@ "percentage": 88.23 }, "lines": { - "covered": 469, + "covered": 480, "total": 590, - "percentage": 79.49 + "percentage": 81.35 } }, "src/vs/platform/agentHost/node/claude/claudeTransportMode.ts": { @@ -5340,24 +6286,24 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts": { "statements": { - "covered": 169, + "covered": 171, "total": 237, - "percentage": 71.3 + "percentage": 72.15 }, "branches": { - "covered": 10, - "total": 14, - "percentage": 71.42 + "covered": 11, + "total": 15, + "percentage": 73.33 }, "functions": { - "covered": 9, + "covered": 10, "total": 14, - "percentage": 64.28 + "percentage": 71.42 }, "lines": { - "covered": 169, + "covered": 171, "total": 237, - "percentage": 71.3 + "percentage": 72.15 } }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts": { @@ -5516,36 +6462,36 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 4475, - "total": 6780, - "percentage": 66 + "covered": 5563, + "total": 7918, + "percentage": 70.25 }, "branches": { - "covered": 517, - "total": 908, - "percentage": 56.93 + "covered": 827, + "total": 1331, + "percentage": 62.13 }, "functions": { - "covered": 186, - "total": 256, - "percentage": 72.65 + "covered": 233, + "total": 294, + "percentage": 79.25 }, "lines": { - "covered": 4475, - "total": 6780, - "percentage": 66 + "covered": 5563, + "total": 7918, + "percentage": 70.25 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { "statements": { - "covered": 414, + "covered": 417, "total": 481, - "percentage": 86.07 + "percentage": 86.69 }, "branches": { - "covered": 38, - "total": 59, - "percentage": 64.4 + "covered": 42, + "total": 62, + "percentage": 67.74 }, "functions": { "covered": 17, @@ -5553,21 +6499,21 @@ "percentage": 89.47 }, "lines": { - "covered": 414, + "covered": 417, "total": 481, - "percentage": 86.07 + "percentage": 86.69 } }, "src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts": { "statements": { - "covered": 302, + "covered": 306, "total": 372, - "percentage": 81.18 + "percentage": 82.25 }, "branches": { - "covered": 32, - "total": 63, - "percentage": 50.79 + "covered": 33, + "total": 65, + "percentage": 50.76 }, "functions": { "covered": 20, @@ -5575,31 +6521,31 @@ "percentage": 83.33 }, "lines": { - "covered": 302, + "covered": 306, "total": 372, - "percentage": 81.18 + "percentage": 82.25 } }, "src/vs/platform/agentHost/node/codex/codexCustomizations.ts": { "statements": { - "covered": 213, - "total": 286, - "percentage": 74.47 + "covered": 287, + "total": 335, + "percentage": 85.67 }, "branches": { - "covered": 17, - "total": 35, - "percentage": 48.57 + "covered": 31, + "total": 51, + "percentage": 60.78 }, "functions": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 9, + "total": 10, + "percentage": 90 }, "lines": { - "covered": 213, - "total": 286, - "percentage": 74.47 + "covered": 287, + "total": 335, + "percentage": 85.67 } }, "src/vs/platform/agentHost/node/codex/codexDelegation.ts": { @@ -5670,31 +6616,31 @@ }, "src/vs/platform/agentHost/node/codex/codexForkPlan.ts": { "statements": { - "covered": 54, + "covered": 80, "total": 86, - "percentage": 62.79 + "percentage": 93.02 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 3, + "total": 7, + "percentage": 42.85 }, "functions": { - "covered": 0, + "covered": 2, "total": 2, - "percentage": 0 + "percentage": 100 }, "lines": { - "covered": 54, + "covered": 80, "total": 86, - "percentage": 62.79 + "percentage": 93.02 } }, "src/vs/platform/agentHost/node/codex/codexGuardianReview.ts": { "statements": { - "covered": 81, - "total": 201, - "percentage": 40.29 + "covered": 85, + "total": 220, + "percentage": 38.63 }, "branches": { "covered": 0, @@ -5703,13 +6649,13 @@ }, "functions": { "covered": 0, - "total": 9, + "total": 10, "percentage": 0 }, "lines": { - "covered": 81, - "total": 201, - "percentage": 40.29 + "covered": 85, + "total": 220, + "percentage": 38.63 } }, "src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts": { @@ -5719,9 +6665,9 @@ "percentage": 73.84 }, "branches": { - "covered": 5, - "total": 18, - "percentage": 27.77 + "covered": 7, + "total": 19, + "percentage": 36.84 }, "functions": { "covered": 3, @@ -5736,14 +6682,14 @@ }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 657, + "covered": 654, "total": 1281, - "percentage": 51.28 + "percentage": 51.05 }, "branches": { - "covered": 61, - "total": 116, - "percentage": 52.58 + "covered": 69, + "total": 124, + "percentage": 55.64 }, "functions": { "covered": 19, @@ -5751,9 +6697,9 @@ "percentage": 44.18 }, "lines": { - "covered": 657, + "covered": 654, "total": 1281, - "percentage": 51.28 + "percentage": 51.05 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { @@ -5763,9 +6709,9 @@ "percentage": 83.44 }, "branches": { - "covered": 44, - "total": 61, - "percentage": 72.13 + "covered": 50, + "total": 67, + "percentage": 74.62 }, "functions": { "covered": 20, @@ -5778,16 +6724,38 @@ "percentage": 83.44 } }, + "src/vs/platform/agentHost/node/codex/codexProfileImage.ts": { + "statements": { + "covered": 87, + "total": 363, + "percentage": 23.96 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 20, + "percentage": 0 + }, + "lines": { + "covered": 87, + "total": 363, + "percentage": 23.96 + } + }, "src/vs/platform/agentHost/node/codex/codexPromptResolver.ts": { "statements": { - "covered": 79, - "total": 194, - "percentage": 40.72 + "covered": 121, + "total": 206, + "percentage": 58.73 }, "branches": { - "covered": 2, - "total": 4, - "percentage": 50 + "covered": 11, + "total": 17, + "percentage": 64.7 }, "functions": { "covered": 1, @@ -5795,9 +6763,9 @@ "percentage": 25 }, "lines": { - "covered": 79, - "total": 194, - "percentage": 40.72 + "covered": 121, + "total": 206, + "percentage": 58.73 } }, "src/vs/platform/agentHost/node/codex/codexProviderConfiguration.ts": { @@ -5824,14 +6792,14 @@ }, "src/vs/platform/agentHost/node/codex/codexProxyService.ts": { "statements": { - "covered": 390, + "covered": 393, "total": 484, - "percentage": 80.57 + "percentage": 81.19 }, "branches": { - "covered": 33, - "total": 79, - "percentage": 41.77 + "covered": 36, + "total": 82, + "percentage": 43.9 }, "functions": { "covered": 11, @@ -5839,43 +6807,43 @@ "percentage": 78.57 }, "lines": { - "covered": 390, + "covered": 393, "total": 484, - "percentage": 80.57 + "percentage": 81.19 } }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 79, - "total": 378, - "percentage": 20.89 + "covered": 178, + "total": 380, + "percentage": 46.84 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 6, + "total": 26, + "percentage": 23.07 }, "functions": { - "covered": 0, - "total": 12, - "percentage": 0 + "covered": 4, + "total": 13, + "percentage": 30.76 }, "lines": { - "covered": 79, - "total": 378, - "percentage": 20.89 + "covered": 178, + "total": 380, + "percentage": 46.84 } }, "src/vs/platform/agentHost/node/codex/codexRolloutMetadata.ts": { "statements": { - "covered": 117, + "covered": 123, "total": 159, - "percentage": 73.58 + "percentage": 77.35 }, "branches": { - "covered": 25, - "total": 34, - "percentage": 73.52 + "covered": 27, + "total": 35, + "percentage": 77.14 }, "functions": { "covered": 5, @@ -5883,9 +6851,9 @@ "percentage": 100 }, "lines": { - "covered": 117, + "covered": 123, "total": 159, - "percentage": 73.58 + "percentage": 77.35 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -5895,9 +6863,9 @@ "percentage": 87.87 }, "branches": { - "covered": 22, - "total": 44, - "percentage": 50 + "covered": 24, + "total": 45, + "percentage": 53.33 }, "functions": { "covered": 11, @@ -5912,14 +6880,14 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 230, + "covered": 233, "total": 256, - "percentage": 89.84 + "percentage": 91.01 }, "branches": { - "covered": 20, - "total": 37, - "percentage": 54.05 + "covered": 32, + "total": 47, + "percentage": 68.08 }, "functions": { "covered": 7, @@ -5927,9 +6895,9 @@ "percentage": 100 }, "lines": { - "covered": 230, + "covered": 233, "total": 256, - "percentage": 89.84 + "percentage": 91.01 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { @@ -5956,24 +6924,24 @@ }, "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { "statements": { - "covered": 42, + "covered": 67, "total": 189, - "percentage": 22.22 + "percentage": 35.44 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 1, + "total": 9, + "percentage": 11.11 }, "functions": { - "covered": 0, + "covered": 1, "total": 8, - "percentage": 0 + "percentage": 12.5 }, "lines": { - "covered": 42, + "covered": 67, "total": 189, - "percentage": 22.22 + "percentage": 35.44 } }, "src/vs/platform/agentHost/node/codex/codexThreadList.ts": { @@ -6000,24 +6968,24 @@ }, "src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts": { "statements": { - "covered": 32, + "covered": 78, "total": 88, - "percentage": 36.36 - }, - "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "percentage": 88.63 + }, + "branches": { + "covered": 12, + "total": 18, + "percentage": 66.66 }, "functions": { - "covered": 0, + "covered": 4, "total": 4, - "percentage": 0 + "percentage": 100 }, "lines": { - "covered": 32, + "covered": 78, "total": 88, - "percentage": 36.36 + "percentage": 88.63 } }, "src/vs/platform/agentHost/node/codexCompactCommand.ts": { @@ -6044,14 +7012,14 @@ }, "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { - "covered": 567, + "covered": 563, "total": 704, - "percentage": 80.53 + "percentage": 79.97 }, "branches": { - "covered": 39, - "total": 82, - "percentage": 47.56 + "covered": 29, + "total": 76, + "percentage": 38.15 }, "functions": { "covered": 18, @@ -6059,9 +7027,9 @@ "percentage": 85.71 }, "lines": { - "covered": 567, + "covered": 563, "total": 704, - "percentage": 80.53 + "percentage": 79.97 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { @@ -6154,68 +7122,68 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 4880, - "total": 6477, - "percentage": 75.34 + "covered": 5126, + "total": 6888, + "percentage": 74.41 }, "branches": { - "covered": 805, - "total": 1225, - "percentage": 65.71 + "covered": 867, + "total": 1283, + "percentage": 67.57 }, "functions": { - "covered": 275, - "total": 335, - "percentage": 82.08 + "covered": 281, + "total": 350, + "percentage": 80.28 }, "lines": { - "covered": 4880, - "total": 6477, - "percentage": 75.34 + "covered": 5126, + "total": 6888, + "percentage": 74.41 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4460, - "total": 6062, - "percentage": 73.57 + "covered": 4807, + "total": 6656, + "percentage": 72.22 }, "branches": { - "covered": 822, - "total": 1183, - "percentage": 69.48 + "covered": 914, + "total": 1331, + "percentage": 68.67 }, "functions": { - "covered": 197, - "total": 244, - "percentage": 80.73 + "covered": 210, + "total": 263, + "percentage": 79.84 }, "lines": { - "covered": 4460, - "total": 6062, - "percentage": 73.57 + "covered": 4807, + "total": 6656, + "percentage": 72.22 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts": { "statements": { - "covered": 38, + "covered": 45, "total": 45, - "percentage": 84.44 + "percentage": 100 }, "branches": { - "covered": 6, - "total": 6, - "percentage": 100 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "functions": { - "covered": 3, + "covered": 5, "total": 5, - "percentage": 60 + "percentage": 100 }, "lines": { - "covered": 38, + "covered": 45, "total": 45, - "percentage": 84.44 + "percentage": 100 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -6242,14 +7210,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts": { "statements": { - "covered": 32, - "total": 32, + "covered": 40, + "total": 40, "percentage": 100 }, "branches": { - "covered": 7, - "total": 7, - "percentage": 100 + "covered": 8, + "total": 9, + "percentage": 88.88 }, "functions": { "covered": 1, @@ -6257,8 +7225,8 @@ "percentage": 100 }, "lines": { - "covered": 32, - "total": 32, + "covered": 40, + "total": 40, "percentage": 100 } }, @@ -6286,14 +7254,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { "statements": { - "covered": 250, - "total": 256, - "percentage": 97.65 + "covered": 244, + "total": 248, + "percentage": 98.38 }, "branches": { "covered": 5, - "total": 10, - "percentage": 50 + "total": 9, + "percentage": 55.55 }, "functions": { "covered": 2, @@ -6301,9 +7269,9 @@ "percentage": 100 }, "lines": { - "covered": 250, - "total": 256, - "percentage": 97.65 + "covered": 244, + "total": 248, + "percentage": 98.38 } }, "src/vs/platform/agentHost/node/copilot/copilotGitProject.ts": { @@ -6352,24 +7320,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 375, - "total": 520, - "percentage": 72.11 + "covered": 392, + "total": 539, + "percentage": 72.72 }, "branches": { - "covered": 42, - "total": 78, - "percentage": 53.84 + "covered": 44, + "total": 82, + "percentage": 53.65 }, "functions": { - "covered": 19, - "total": 25, - "percentage": 76 + "covered": 20, + "total": 26, + "percentage": 76.92 }, "lines": { - "covered": 375, - "total": 520, - "percentage": 72.11 + "covered": 392, + "total": 539, + "percentage": 72.72 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -6380,8 +7348,8 @@ }, "branches": { "covered": 3, - "total": 16, - "percentage": 18.75 + "total": 15, + "percentage": 20 }, "functions": { "covered": 3, @@ -6394,48 +7362,70 @@ "percentage": 90.62 } }, + "src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts": { + "statements": { + "covered": 28, + "total": 31, + "percentage": 90.32 + }, + "branches": { + "covered": 2, + "total": 5, + "percentage": 40 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 28, + "total": 31, + "percentage": 90.32 + } + }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 701, - "total": 906, - "percentage": 77.37 + "covered": 819, + "total": 1046, + "percentage": 78.29 }, "branches": { - "covered": 91, - "total": 136, - "percentage": 66.91 + "covered": 102, + "total": 157, + "percentage": 64.96 }, "functions": { - "covered": 35, - "total": 47, - "percentage": 74.46 + "covered": 42, + "total": 52, + "percentage": 80.76 }, "lines": { - "covered": 701, - "total": 906, - "percentage": 77.37 + "covered": 819, + "total": 1046, + "percentage": 78.29 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { "statements": { - "covered": 324, - "total": 338, - "percentage": 95.85 + "covered": 329, + "total": 343, + "percentage": 95.91 }, "branches": { - "covered": 66, - "total": 66, + "covered": 67, + "total": 67, "percentage": 100 }, "functions": { - "covered": 56, - "total": 58, - "percentage": 96.55 + "covered": 57, + "total": 59, + "percentage": 96.61 }, "lines": { - "covered": 324, - "total": 338, - "percentage": 95.85 + "covered": 329, + "total": 343, + "percentage": 95.91 } }, "src/vs/platform/agentHost/node/copilot/copilotShellTools.ts": { @@ -6463,8 +7453,8 @@ "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { "statements": { "covered": 100, - "total": 270, - "percentage": 37.03 + "total": 271, + "percentage": 36.9 }, "branches": { "covered": 2, @@ -6478,8 +7468,8 @@ }, "lines": { "covered": 100, - "total": 270, - "percentage": 37.03 + "total": 271, + "percentage": 36.9 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts": { @@ -6550,36 +7540,36 @@ }, "src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts": { "statements": { - "covered": 977, - "total": 1214, - "percentage": 80.47 + "covered": 1008, + "total": 1258, + "percentage": 80.12 }, "branches": { - "covered": 165, - "total": 278, - "percentage": 59.35 + "covered": 168, + "total": 283, + "percentage": 59.36 }, "functions": { - "covered": 27, - "total": 35, - "percentage": 77.14 + "covered": 29, + "total": 37, + "percentage": 78.37 }, "lines": { - "covered": 977, - "total": 1214, - "percentage": 80.47 + "covered": 1008, + "total": 1258, + "percentage": 80.12 } }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 672, - "total": 956, - "percentage": 70.29 + "covered": 725, + "total": 1021, + "percentage": 71 }, "branches": { - "covered": 85, - "total": 166, - "percentage": 51.2 + "covered": 95, + "total": 191, + "percentage": 49.73 }, "functions": { "covered": 21, @@ -6587,30 +7577,52 @@ "percentage": 95.45 }, "lines": { - "covered": 672, - "total": 956, - "percentage": 70.29 + "covered": 725, + "total": 1021, + "percentage": 71 + } + }, + "src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts": { + "statements": { + "covered": 60, + "total": 69, + "percentage": 86.95 + }, + "branches": { + "covered": 8, + "total": 13, + "percentage": 61.53 + }, + "functions": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "lines": { + "covered": 60, + "total": 69, + "percentage": 86.95 } }, "src/vs/platform/agentHost/node/copilot/modelIdentifiers.ts": { "statements": { - "covered": 14, - "total": 14, + "covered": 19, + "total": 19, "percentage": 100 }, "branches": { - "covered": 2, - "total": 2, + "covered": 3, + "total": 3, "percentage": 100 }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 14, - "total": 14, + "covered": 19, + "total": 19, "percentage": 100 } }, @@ -6680,33 +7692,55 @@ "percentage": 71.21 } }, + "src/vs/platform/agentHost/node/copilot/prompts/promptOverride.ts": { + "statements": { + "covered": 45, + "total": 133, + "percentage": 33.83 + }, + "branches": { + "covered": 1, + "total": 5, + "percentage": 20 + }, + "functions": { + "covered": 1, + "total": 6, + "percentage": 16.66 + }, + "lines": { + "covered": 45, + "total": 133, + "percentage": 33.83 + } + }, "src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts": { "statements": { - "covered": 206, - "total": 229, - "percentage": 89.95 + "covered": 210, + "total": 233, + "percentage": 90.12 }, "branches": { - "covered": 13, - "total": 23, - "percentage": 56.52 + "covered": 14, + "total": 24, + "percentage": 58.33 }, "functions": { - "covered": 7, - "total": 7, + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 206, - "total": 229, - "percentage": 89.95 + "covered": 210, + "total": 233, + "percentage": 90.12 } }, "src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts": { "statements": { - "covered": 116, - "total": 127, - "percentage": 91.33 + "covered": 115, + "total": 126, + "percentage": 91.26 }, "branches": { "covered": 5, @@ -6719,43 +7753,43 @@ "percentage": 40 }, "lines": { - "covered": 116, - "total": 127, - "percentage": 91.33 + "covered": 115, + "total": 126, + "percentage": 91.26 } }, "src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts": { "statements": { - "covered": 119, - "total": 138, - "percentage": 86.23 + "covered": 144, + "total": 163, + "percentage": 88.34 }, "branches": { - "covered": 8, - "total": 19, - "percentage": 42.1 + "covered": 9, + "total": 21, + "percentage": 42.85 }, "functions": { - "covered": 6, - "total": 7, - "percentage": 85.71 + "covered": 7, + "total": 8, + "percentage": 87.5 }, "lines": { - "covered": 119, - "total": 138, - "percentage": 86.23 + "covered": 144, + "total": 163, + "percentage": 88.34 } }, "src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts": { "statements": { - "covered": 169, - "total": 190, - "percentage": 88.94 + "covered": 177, + "total": 206, + "percentage": 85.92 }, "branches": { "covered": 1, - "total": 23, - "percentage": 4.34 + "total": 24, + "percentage": 4.16 }, "functions": { "covered": 1, @@ -6763,21 +7797,21 @@ "percentage": 100 }, "lines": { - "covered": 169, - "total": 190, - "percentage": 88.94 + "covered": 177, + "total": 206, + "percentage": 85.92 } }, "src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts": { "statements": { - "covered": 1151, - "total": 1279, - "percentage": 89.99 + "covered": 1152, + "total": 1289, + "percentage": 89.37 }, "branches": { - "covered": 229, - "total": 281, - "percentage": 81.49 + "covered": 233, + "total": 288, + "percentage": 80.9 }, "functions": { "covered": 38, @@ -6785,16 +7819,16 @@ "percentage": 97.43 }, "lines": { - "covered": 1151, - "total": 1279, - "percentage": 89.99 + "covered": 1152, + "total": 1289, + "percentage": 89.37 } }, "src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts": { "statements": { - "covered": 26, - "total": 45, - "percentage": 57.77 + "covered": 23, + "total": 42, + "percentage": 54.76 }, "branches": { "covered": 0, @@ -6807,21 +7841,21 @@ "percentage": 0 }, "lines": { - "covered": 26, - "total": 45, - "percentage": 57.77 + "covered": 23, + "total": 42, + "percentage": 54.76 } }, "src/vs/platform/agentHost/node/diffComputeService.ts": { "statements": { - "covered": 81, + "covered": 79, "total": 102, - "percentage": 79.41 + "percentage": 77.45 }, "branches": { - "covered": 11, + "covered": 10, "total": 15, - "percentage": 73.33 + "percentage": 66.66 }, "functions": { "covered": 6, @@ -6829,9 +7863,9 @@ "percentage": 85.71 }, "lines": { - "covered": 81, + "covered": 79, "total": 102, - "percentage": 79.41 + "percentage": 77.45 } }, "src/vs/platform/agentHost/node/diffWorkerMain.ts": { @@ -6902,14 +7936,14 @@ }, "src/vs/platform/agentHost/node/localCommands/localChatCommand.ts": { "statements": { - "covered": 244, - "total": 255, - "percentage": 95.68 + "covered": 239, + "total": 250, + "percentage": 95.6 }, "branches": { - "covered": 28, + "covered": 27, "total": 36, - "percentage": 77.77 + "percentage": 75 }, "functions": { "covered": 13, @@ -6917,9 +7951,9 @@ "percentage": 100 }, "lines": { - "covered": 244, - "total": 255, - "percentage": 95.68 + "covered": 239, + "total": 250, + "percentage": 95.6 } }, "src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts": { @@ -6973,9 +8007,9 @@ "percentage": 94.89 }, "branches": { - "covered": 31, - "total": 38, - "percentage": 81.57 + "covered": 30, + "total": 37, + "percentage": 81.08 }, "functions": { "covered": 8, @@ -7034,24 +8068,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1704, - "total": 1947, - "percentage": 87.51 + "covered": 1803, + "total": 2128, + "percentage": 84.72 }, "branches": { - "covered": 350, - "total": 439, - "percentage": 79.72 + "covered": 378, + "total": 490, + "percentage": 77.14 }, "functions": { - "covered": 85, - "total": 95, - "percentage": 89.47 + "covered": 88, + "total": 96, + "percentage": 91.66 }, "lines": { - "covered": 1704, - "total": 1947, - "percentage": 87.51 + "covered": 1803, + "total": 2128, + "percentage": 84.72 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -7076,82 +8110,60 @@ "percentage": 50 } }, - "src/vs/platform/agentHost/node/sessionCoordination.ts": { - "statements": { - "covered": 78, - "total": 159, - "percentage": 49.05 - }, - "branches": { - "covered": 10, - "total": 17, - "percentage": 58.82 - }, - "functions": { - "covered": 4, - "total": 6, - "percentage": 66.66 - }, - "lines": { - "covered": 78, - "total": 159, - "percentage": 49.05 - } - }, "src/vs/platform/agentHost/node/sessionDataService.ts": { "statements": { - "covered": 157, - "total": 198, - "percentage": 79.29 + "covered": 166, + "total": 207, + "percentage": 80.19 }, "branches": { - "covered": 22, - "total": 25, - "percentage": 88 + "covered": 29, + "total": 33, + "percentage": 87.87 }, "functions": { - "covered": 12, - "total": 14, - "percentage": 85.71 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "lines": { - "covered": 157, - "total": 198, - "percentage": 79.29 + "covered": 166, + "total": 207, + "percentage": 80.19 } }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 772, - "total": 909, - "percentage": 84.92 + "covered": 816, + "total": 946, + "percentage": 86.25 }, "branches": { - "covered": 121, - "total": 150, - "percentage": 80.66 + "covered": 141, + "total": 173, + "percentage": 81.5 }, "functions": { - "covered": 41, - "total": 55, - "percentage": 74.54 + "covered": 43, + "total": 57, + "percentage": 75.43 }, "lines": { - "covered": 772, - "total": 909, - "percentage": 84.92 + "covered": 816, + "total": 946, + "percentage": 86.25 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { "statements": { - "covered": 277, + "covered": 271, "total": 507, - "percentage": 54.63 + "percentage": 53.45 }, "branches": { - "covered": 24, - "total": 43, - "percentage": 55.81 + "covered": 21, + "total": 41, + "percentage": 51.21 }, "functions": { "covered": 4, @@ -7159,38 +8171,38 @@ "percentage": 66.66 }, "lines": { - "covered": 277, + "covered": 271, "total": 507, - "percentage": 54.63 + "percentage": 53.45 } }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 549, - "total": 707, - "percentage": 77.65 + "covered": 573, + "total": 760, + "percentage": 75.39 }, "branches": { - "covered": 94, - "total": 137, - "percentage": 68.61 + "covered": 85, + "total": 132, + "percentage": 64.39 }, "functions": { - "covered": 24, - "total": 29, - "percentage": 82.75 + "covered": 25, + "total": 30, + "percentage": 83.33 }, "lines": { - "covered": 549, - "total": 707, - "percentage": 77.65 + "covered": 573, + "total": 760, + "percentage": 75.39 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { "statements": { - "covered": 129, - "total": 169, - "percentage": 76.33 + "covered": 134, + "total": 174, + "percentage": 77.01 }, "branches": { "covered": 7, @@ -7203,9 +8215,9 @@ "percentage": 85.71 }, "lines": { - "covered": 129, - "total": 169, - "percentage": 76.33 + "covered": 134, + "total": 174, + "percentage": 77.01 } }, "src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts": { @@ -7237,9 +8249,9 @@ "percentage": 89.82 }, "branches": { - "covered": 60, - "total": 98, - "percentage": 61.22 + "covered": 61, + "total": 99, + "percentage": 61.61 }, "functions": { "covered": 26, @@ -7281,9 +8293,9 @@ "percentage": 48 }, "branches": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 4, + "total": 4, + "percentage": 100 }, "functions": { "covered": 2, @@ -7296,26 +8308,48 @@ "percentage": 48 } }, + "src/vs/platform/agentHost/node/shared/agentMergeToolRestrictions.ts": { + "statements": { + "covered": 19, + "total": 37, + "percentage": 51.35 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 3, + "percentage": 0 + }, + "lines": { + "covered": 19, + "total": 37, + "percentage": 51.35 + } + }, "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { "statements": { - "covered": 192, - "total": 203, - "percentage": 94.58 + "covered": 247, + "total": 258, + "percentage": 95.73 }, "branches": { - "covered": 23, - "total": 31, - "percentage": 74.19 + "covered": 44, + "total": 53, + "percentage": 83.01 }, "functions": { - "covered": 11, - "total": 11, + "covered": 12, + "total": 12, "percentage": 100 }, "lines": { - "covered": 192, - "total": 203, - "percentage": 94.58 + "covered": 247, + "total": 258, + "percentage": 95.73 } }, "src/vs/platform/agentHost/node/shared/arcToolEdit.ts": { @@ -7342,24 +8376,24 @@ }, "src/vs/platform/agentHost/node/shared/artifactServerTools.ts": { "statements": { - "covered": 108, - "total": 175, - "percentage": 61.71 + "covered": 198, + "total": 209, + "percentage": 94.73 }, "branches": { - "covered": 2, - "total": 2, - "percentage": 100 + "covered": 37, + "total": 50, + "percentage": 74 }, "functions": { - "covered": 2, - "total": 8, - "percentage": 25 + "covered": 9, + "total": 9, + "percentage": 100 }, "lines": { - "covered": 108, - "total": 175, - "percentage": 61.71 + "covered": 198, + "total": 209, + "percentage": 94.73 } }, "src/vs/platform/agentHost/node/shared/copilotApiService.ts": { @@ -7369,9 +8403,9 @@ "percentage": 87.31 }, "branches": { - "covered": 52, - "total": 91, - "percentage": 57.14 + "covered": 60, + "total": 98, + "percentage": 61.22 }, "functions": { "covered": 25, @@ -7408,9 +8442,9 @@ }, "src/vs/platform/agentHost/node/shared/editArcReporter.ts": { "statements": { - "covered": 145, - "total": 393, - "percentage": 36.89 + "covered": 149, + "total": 397, + "percentage": 37.53 }, "branches": { "covered": 6, @@ -7423,9 +8457,9 @@ "percentage": 25 }, "lines": { - "covered": 145, - "total": 393, - "percentage": 36.89 + "covered": 149, + "total": 397, + "percentage": 37.53 } }, "src/vs/platform/agentHost/node/shared/editChunkExtractor.ts": { @@ -7474,14 +8508,14 @@ }, "src/vs/platform/agentHost/node/shared/editSurvivalTracker.ts": { "statements": { - "covered": 209, + "covered": 205, "total": 236, - "percentage": 88.55 + "percentage": 86.86 }, "branches": { - "covered": 21, - "total": 28, - "percentage": 75 + "covered": 18, + "total": 26, + "percentage": 69.23 }, "functions": { "covered": 6, @@ -7489,9 +8523,9 @@ "percentage": 75 }, "lines": { - "covered": 209, + "covered": 205, "total": 236, - "percentage": 88.55 + "percentage": 86.86 } }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { @@ -7501,9 +8535,9 @@ "percentage": 94.46 }, "branches": { - "covered": 35, - "total": 41, - "percentage": 85.36 + "covered": 29, + "total": 35, + "percentage": 82.85 }, "functions": { "covered": 7, @@ -7562,14 +8596,14 @@ }, "src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts": { "statements": { - "covered": 286, + "covered": 287, "total": 332, - "percentage": 86.14 + "percentage": 86.44 }, "branches": { - "covered": 22, - "total": 32, - "percentage": 68.75 + "covered": 23, + "total": 34, + "percentage": 67.64 }, "functions": { "covered": 10, @@ -7577,31 +8611,31 @@ "percentage": 76.92 }, "lines": { - "covered": 286, + "covered": 287, "total": 332, - "percentage": 86.14 + "percentage": 86.44 } }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 487, + "covered": 506, "total": 586, - "percentage": 83.1 + "percentage": 86.34 }, "branches": { - "covered": 105, - "total": 122, - "percentage": 86.06 + "covered": 109, + "total": 129, + "percentage": 84.49 }, "functions": { - "covered": 26, + "covered": 28, "total": 32, - "percentage": 81.25 + "percentage": 87.5 }, "lines": { - "covered": 487, + "covered": 506, "total": 586, - "percentage": 83.1 + "percentage": 86.34 } }, "src/vs/platform/agentHost/node/shared/mcpServerWorkingDirectory.ts": { @@ -7626,6 +8660,28 @@ "percentage": 90 } }, + "src/vs/platform/agentHost/node/shared/modelRefreshRetry.ts": { + "statements": { + "covered": 13, + "total": 16, + "percentage": 81.25 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 13, + "total": 16, + "percentage": 81.25 + } + }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { "statements": { "covered": 57, @@ -7672,14 +8728,14 @@ }, "src/vs/platform/agentHost/node/shared/serverToolGroups.ts": { "statements": { - "covered": 77, - "total": 79, - "percentage": 97.46 + "covered": 85, + "total": 91, + "percentage": 93.4 }, "branches": { - "covered": 9, - "total": 10, - "percentage": 90 + "covered": 13, + "total": 16, + "percentage": 81.25 }, "functions": { "covered": 3, @@ -7687,21 +8743,21 @@ "percentage": 100 }, "lines": { - "covered": 77, - "total": 79, - "percentage": 97.46 + "covered": 85, + "total": 91, + "percentage": 93.4 } }, "src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts": { "statements": { - "covered": 162, + "covered": 166, "total": 200, - "percentage": 81 + "percentage": 83 }, "branches": { - "covered": 27, - "total": 33, - "percentage": 81.81 + "covered": 35, + "total": 40, + "percentage": 87.5 }, "functions": { "covered": 10, @@ -7709,31 +8765,31 @@ "percentage": 100 }, "lines": { - "covered": 162, + "covered": 166, "total": 200, - "percentage": 81 + "percentage": 83 } }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 1112, - "total": 1400, - "percentage": 79.42 + "covered": 1228, + "total": 1465, + "percentage": 83.82 }, "branches": { - "covered": 160, - "total": 276, - "percentage": 57.97 + "covered": 214, + "total": 358, + "percentage": 59.77 }, "functions": { - "covered": 49, - "total": 60, - "percentage": 81.66 + "covered": 57, + "total": 63, + "percentage": 90.47 }, "lines": { - "covered": 1112, - "total": 1400, - "percentage": 79.42 + "covered": 1228, + "total": 1465, + "percentage": 83.82 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -7758,26 +8814,48 @@ "percentage": 61.82 } }, + "src/vs/platform/agentHost/node/shared/toolCallContributor.ts": { + "statements": { + "covered": 48, + "total": 49, + "percentage": 97.95 + }, + "branches": { + "covered": 10, + "total": 11, + "percentage": 90.9 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 48, + "total": 49, + "percentage": 97.95 + } + }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 908, - "total": 1234, - "percentage": 73.58 + "covered": 1191, + "total": 1529, + "percentage": 77.89 }, "branches": { - "covered": 96, - "total": 163, - "percentage": 58.89 + "covered": 158, + "total": 236, + "percentage": 66.94 }, "functions": { - "covered": 39, - "total": 52, - "percentage": 75 + "covered": 49, + "total": 87, + "percentage": 56.32 }, "lines": { - "covered": 908, - "total": 1234, - "percentage": 73.58 + "covered": 1191, + "total": 1529, + "percentage": 77.89 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { @@ -7804,24 +8882,24 @@ }, "src/vs/platform/agentHost/node/workspacelessScratchDir.ts": { "statements": { - "covered": 20, - "total": 25, - "percentage": 80 + "covered": 16, + "total": 21, + "percentage": 76.19 }, "branches": { - "covered": 1, - "total": 1, + "covered": 0, + "total": 0, "percentage": 100 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 0, + "total": 1, + "percentage": 0 }, "lines": { - "covered": 20, - "total": 25, - "percentage": 80 + "covered": 16, + "total": 21, + "percentage": 76.19 } } } diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index 424b794d0c5b6c..3aefe4bf299509 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -25,6 +25,8 @@ import { defineTurnLifecycleTests } from './turnLifecycleSuite.js'; import { defineWorkspaceTests } from './workspaceSuite.js'; import { defineCopilotCoverageTests } from './copilotCoverageSuite.js'; import { defineManagementExtensionTests } from './managementExtensionsSuite.js'; +import { defineAutomationsTests } from './automationsSuite.js'; +import { defineDetachedWorktreeTests } from './detachedWorktreeSuite.js'; import type { AgentHostE2ETier, IAgentHostE2ETestContext } from './e2eTestContext.js'; const isLinux = process.platform === 'linux'; @@ -146,12 +148,14 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption // Suites that contain only conformance-tier scenarios. if (options.tier === 'conformance') { + defineAutomationsTests(context); defineHostFeaturesTests(context); defineStateOperationsTests(context); defineClientFilesystemTests(context); defineClientHostedFilesystemTests(context); defineAnnotationsTests(context); defineProtocolContractTests(context); + defineDetachedWorktreeTests(context); } // Suites that contain only parity-tier scenarios. diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts new file mode 100644 index 00000000000000..f5e065f088b381 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { equals } from '../../../../../../base/common/objects.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY } from '../../../../common/automationMigration.js'; +import type { FetchAutomationRunsResult, InitializeResult, ListAutomationTriggerDefinitionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { AutomationOperation, type AutomationDefinition, type AutomationEntry } from '../../../../common/state/protocol/state.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { ActionType, type AutomationRemovedAction, type AutomationSetAction } from '../../../../common/state/sessionActions.js'; +import type { AhpNotification } from '../../../../common/state/sessionProtocol.js'; +import { AUTOMATION_CATALOG_URI, MessageKind, ROOT_STATE_URI, type AutomationState, type RootState } from '../../../../common/state/sessionState.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +/** The migration gate's message, checked before the enablement gate's. */ +const MIGRATION_REQUIRED_MESSAGE = 'Automation migration must complete before automations can be accessed or run.'; +const AUTOMATIONS_DISABLED_MESSAGE = 'Automations are disabled.'; +/** Mirrors the host's advertised `runHistoryLimit`. */ +const RUN_HISTORY_LIMIT = 50; + +const UNGATED_OPERATIONS = [AutomationOperation.Update, AutomationOperation.Remove]; +const GATED_OPERATIONS = [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run]; + +/** + * The host-owned automation catalogue, exercised entirely over AHP. + * + * Everything here stays on the host side of the model boundary: an automation + * is only a durable definition until something starts a run, and no test here + * runs one. Every definition is therefore manual-only (`triggers: []`), which + * also keeps the host's cron scheduler — which reads the real clock and has no + * injectable seam — out of the suite. + */ +export function defineAutomationsTests(context: IAgentHostE2ETestContext): void { + const { config } = context; + let clientSeq = 1; + + function nextClientSeq(): number { + return clientSeq++; + } + + async function initializeRoot(prefix: string): Promise { + return context.client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `${prefix}-${config.provider}`, + }, 30_000); + } + + async function rootConfigValues(): Promise>> { + const result = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + return (result.snapshot!.state as RootState).config?.values ?? {}; + } + + /** + * Replaces one root-config value, skipping the dispatch when the host already + * holds it. An unchanged patch is a deliberate no-op in the state manager: it + * emits no action at all, so waiting for the echo would hang. Both automation + * gates are durable for the life of the shared host, so tests re-open them + * defensively and hit that no-op constantly. + */ + async function setRootConfigValue(key: string, value: unknown): Promise { + if (equals((await rootConfigValues())[key], value)) { + return; + } + const seq = nextClientSeq(); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: seq, + action: { type: ActionType.RootConfigChanged, config: { [key]: value } }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.RootConfigChanged) + && getActionEnvelope(notification).channel === ROOT_STATE_URI + && getActionEnvelope(notification).origin?.clientSeq === seq, + ); + } + + function setAutomationsEnabled(enabled: boolean): Promise { + return setRootConfigValue(AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, enabled); + } + + /** + * Completes automation migration. The host requires this as an isolated + * root-config patch and refuses it while automations are disabled, so it is + * always dispatched on its own and after {@link setAutomationsEnabled}. + */ + function completeAutomationMigration(): Promise { + return setRootConfigValue(AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, { version: 1, status: 'complete', resources: [] }); + } + + /** Opens both gates. Idempotent, so each test can stand on its own. */ + async function openAutomationGates(): Promise { + await setAutomationsEnabled(true); + await completeAutomationMigration(); + } + + async function subscribeCatalog(): Promise { + const result = await context.client.call('subscribe', { channel: AUTOMATION_CATALOG_URI }); + return result.snapshot!.state as AutomationState; + } + + function automationResource(prefix: string): string { + return `ahp-automation:/${prefix}-${generateUuid()}`; + } + + function buildDefinition(title: string): AutomationDefinition { + return { + title, + // An automation message must declare an automation origin, and an empty + // session template is enough for a definition that is never run. + message: { text: 'Reply exactly "ran".', origin: { kind: MessageKind.Automation } }, + session: {}, + enabled: false, + triggers: [], + }; + } + + function automationSetFor(resource: string, accept: (automation: AutomationEntry) => boolean): (notification: AhpNotification) => boolean { + return notification => { + if (!isActionNotification(notification, ActionType.AutomationSet) || getActionEnvelope(notification).channel !== AUTOMATION_CATALOG_URI) { + return false; + } + const { automation } = getActionEnvelope(notification).action as AutomationSetAction; + return automation.resource === resource && accept(automation); + }; + } + + /** + * Waits for the authoritative `automation/set` the host publishes after it has + * persisted a mutation. The client's own `automation/createRequested` is never + * echoed back, so this is the only accept signal; a failed mutation instead + * comes back as a rejected envelope carrying the request's action type. + */ + async function waitForAutomationSet(resource: string, accept: (automation: AutomationEntry) => boolean = () => true): Promise { + const notification = await context.client.waitForNotification(automationSetFor(resource, accept)); + return (getActionEnvelope(notification).action as AutomationSetAction).automation; + } + + async function createAutomation(resource: string, definition: AutomationDefinition): Promise { + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationCreateRequested, resource, definition }, + }); + return waitForAutomationSet(resource); + } + + /** The message a failed request reported, or a marker when it unexpectedly succeeded. */ + async function rejectionMessage(request: Promise): Promise { + try { + await request; + return ''; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + function listTriggerDefinitions(): Promise { + return context.client.call('listAutomationTriggerDefinitions', { channel: ROOT_STATE_URI }); + } + + function entryFor(catalog: AutomationState, resource: string): AutomationEntry | undefined { + return catalog.entries.find(entry => entry.resource === resource); + } + + // Registered first, before anything in this file opens a gate or writes an + // entry: both assertions describe a host that has never had an automation. + conformanceTest(context, 'a fresh agent host advertises the automation catalogue and its capabilities', async function () { + const initialized = await initializeRoot('automations-capabilities'); + + const catalog = await subscribeCatalog(); + + // The catalogue and its commands are advertised before either gate opens: + // a client can always render the (empty) catalogue and author into it. + assert.deepStrictEqual({ + automations: initialized.automations, + entries: catalog.entries, + }, { + automations: { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: RUN_HISTORY_LIMIT }, + entries: [], + }); + }); + + // Migration completion is durable for the life of the host — including across + // restarts, since it is stored alongside the catalogue — so this is the only + // test that can observe the pre-migration gate. It must stay registered ahead + // of every test that calls `openAutomationGates`. + conformanceTest(context, 'automation commands are rejected until automations are enabled and migration completes', async function () { + await initializeRoot('automations-gates'); + + const beforeAnyGate = await rejectionMessage(listTriggerDefinitions()); + await setAutomationsEnabled(true); + const afterEnabling = await rejectionMessage(listTriggerDefinitions()); + await completeAutomationMigration(); + const afterMigration = await listTriggerDefinitions(); + await setAutomationsEnabled(false); + const afterDisabling = await rejectionMessage(listTriggerDefinitions()); + // Leave the host enabled so a later test does not depend on this one's tail. + await setAutomationsEnabled(true); + + // The migration gate is checked first, so enabling alone changes nothing. + // Once both are open the host answers, and the answer is deliberately + // empty: it defines no event triggers today. + assert.deepStrictEqual({ + beforeAnyGate: beforeAnyGate.includes(MIGRATION_REQUIRED_MESSAGE), + afterEnabling: afterEnabling.includes(MIGRATION_REQUIRED_MESSAGE), + afterMigration, + afterDisabling: afterDisabling.includes(AUTOMATIONS_DISABLED_MESSAGE), + }, { + beforeAnyGate: true, + afterEnabling: true, + afterMigration: { items: [] }, + afterDisabling: true, + }); + }); + + conformanceTest(context, 'an automation created while automations are disabled gains its run operation when they are enabled', async function () { + await initializeRoot('automations-run-grant'); + // Granting `run` needs the enablement flag *and* completed migration. + // Migration cannot be undone on a host that has already migrated, so the + // enablement flag is the half of the gate a test can reproduce. + await openAutomationGates(); + await subscribeCatalog(); + await setAutomationsEnabled(false); + const resource = automationResource('run-grant'); + + context.client.clearReceived(); + const whileDisabled = await createAutomation(resource, buildDefinition('Run grant')); + context.client.clearReceived(); + await setAutomationsEnabled(true); + const afterEnabling = await waitForAutomationSet(resource, automation => automation.operations.includes(AutomationOperation.Run)); + + // The definition is authored either way; only the operations a client may + // offer for it change, and the host republishes the entry to say so. + assert.deepStrictEqual({ + whileDisabled: whileDisabled.operations, + afterEnabling: afterEnabling.operations, + title: afterEnabling.definition.title, + }, { + whileDisabled: UNGATED_OPERATIONS, + afterEnabling: GATED_OPERATIONS, + title: 'Run grant', + }); + }); + + conformanceTest(context, 'creating an automation is idempotent only for an identical definition', async function () { + await initializeRoot('automations-idempotent-create'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('idempotent-create'); + const definition = buildDefinition('Stable definition'); + const created = await createAutomation(resource, definition); + + context.client.clearReceived(); + const repeated = await createAutomation(resource, definition); + const conflictingDefinition = buildDefinition('Conflicting definition'); + context.client.clearReceived(); + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationCreateRequested, resource, definition: conflictingDefinition }, + }); + const rejected = await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.AutomationCreateRequested) + && getActionEnvelope(notification).channel === AUTOMATION_CATALOG_URI + && getActionEnvelope(notification).rejectionReason !== undefined, + ); + + assert.deepStrictEqual({ + createdAtUnchanged: repeated.createdAt === created.createdAt, + modifiedAtUnchanged: repeated.modifiedAt === created.modifiedAt, + title: repeated.definition.title, + rejection: getActionEnvelope(rejected).rejectionReason, + }, { + createdAtUnchanged: true, + modifiedAtUnchanged: true, + title: 'Stable definition', + rejection: `Automation already exists: ${resource}`, + }); + }); + + conformanceTest(context, 'updating and removing an automation keeps the catalogue authoritative', async function () { + await initializeRoot('automations-update-remove'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('update-remove'); + await createAutomation(resource, buildDefinition('Original title')); + + context.client.clearReceived(); + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationUpdateRequested, resource, changes: { title: 'Renamed title' } }, + }); + const updated = await waitForAutomationSet(resource); + context.client.clearReceived(); + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationRemoved, resource }, + }); + await context.client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.AutomationRemoved) || getActionEnvelope(notification).channel !== AUTOMATION_CATALOG_URI) { + return false; + } + const envelope = getActionEnvelope(notification) as { rejectionReason?: string; action: AutomationRemovedAction }; + return envelope.action.resource === resource && envelope.rejectionReason === undefined; + }); + const catalog = await subscribeCatalog(); + + // A patch replaces only the fields it names, and removal is republished as + // the same action so every subscriber converges on the host's catalogue. + assert.deepStrictEqual({ + updatedTitle: updated.definition.title, + updatedOperations: updated.operations, + updatedRuns: updated.runs, + survivesRemoval: entryFor(catalog, resource) !== undefined, + }, { + updatedTitle: 'Renamed title', + updatedOperations: GATED_OPERATIONS, + updatedRuns: [], + survivesRemoval: false, + }); + }); + + conformanceTest(context, 'fetchAutomationRuns acknowledges an automation that has never run and rejects an unknown one', async function () { + await initializeRoot('automations-fetch-runs'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('fetch-runs'); + await createAutomation(resource, buildDefinition('Fetch runs')); + const unknownResource = automationResource('fetch-runs-unknown'); + + const acknowledged = await context.client.call('fetchAutomationRuns', { + channel: AUTOMATION_CATALOG_URI, + automation: resource, + }); + const rejected = await rejectionMessage(context.client.call('fetchAutomationRuns', { + channel: AUTOMATION_CATALOG_URI, + automation: unknownResource, + })); + const catalog = await subscribeCatalog(); + + // The result is a bare acknowledgement by contract — run history reaches + // clients through `automation/set` — so an automation with no history has + // nothing to page and nothing to republish. + assert.deepStrictEqual({ + acknowledged, + runs: entryFor(catalog, resource)?.runs, + runsNextCursor: entryFor(catalog, resource)?.runsNextCursor, + rejected: rejected.includes(`Automation not found: ${unknownResource}`), + }, { + acknowledged: {}, + runs: [], + runsNextCursor: undefined, + rejected: true, + }); + }); + + conformanceTest(context, 'a created automation survives an agent host restart', async function () { + await initializeRoot('automations-restart'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('restart'); + const created = await createAutomation(resource, buildDefinition('Survives restart')); + + await context.restartServer(); + await initializeRoot('automations-restart-verify'); + const restored = entryFor(await subscribeCatalog(), resource); + + // The host persists a mutation before it publishes it, so a definition a + // client has seen is recoverable — with its operations — after a restart. + assert.deepStrictEqual({ + created: { title: created.definition.title, operations: created.operations }, + restored: restored && { title: restored.definition.title, operations: restored.operations }, + }, { + created: { title: 'Survives restart', operations: GATED_OPERATIONS }, + restored: { title: 'Survives restart', operations: GATED_OPERATIONS }, + }); + }); +} 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 c05f711fccb4f7..f4365f8d07be95 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -30,6 +30,7 @@ import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentMergeConfigKey } from '../../../../common/agentMerge.js'; import type { ListSessionsResult, ResourceReadResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; @@ -70,6 +71,7 @@ interface IOperationsChangedAction { interface IObservedOperation { readonly id: string; + readonly group?: string; readonly scopes: readonly string[]; readonly status: string; } @@ -91,6 +93,15 @@ const CHANGESET_OPERATION_TIMEOUT_MS = 60_000; export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs } = context; + function parityTest(title: string, run: Mocha.AsyncFunc): void { + if (context.tier === 'parity') { + test(title, function () { + this.timeout(180_000); + return run.call(this); + }); + } + } + /** * Client sequence numbers must strictly increase for the lifetime of a * client, and the suite shares one across tests, so they cannot be @@ -143,6 +154,21 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); } + async function setRootConfig(values: Readonly>): Promise { + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const clientSeq = nextClientSeq(); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq, + action: { type: ActionType.RootConfigChanged, config: values }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.RootConfigChanged) + && getActionEnvelope(notification).channel === ROOT_STATE_URI + && getActionEnvelope(notification).origin?.clientSeq === clientSeq, + ); + } + async function createWorktreeSessionIn(workspace: string, prefix: string): Promise { tempDirs.push(`${workspace}.worktrees`); context.client.setWorkingDirectory(workspace); @@ -789,6 +815,79 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { ]); }); + parityTest('a GitHub remote with changes advertises pull request creation', async function () { + const workspace = createGitWorkspace('ahp-changeset-pr-ops-'); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/microsoft/vscode.git'], { cwd: workspace }); + const sessionUri = await createSessionIn(workspace, 'changeset-pr-ops'); + const uncommittedUri = buildUncommittedChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: uncommittedUri }); + await driveTurnToCompletion(context.client, sessionUri, 'turn-changeset-pr-materialize', 'Reply exactly "ready".', nextClientSeq()); + await runBangTurn(sessionUri, 'turn-changeset-pr-ops', writeFileCommand('pull-request.txt', 'PR'), nextClientSeq()); + + await waitForOperation(uncommittedUri, 'create-pr'); + const operations = (await changesetState(uncommittedUri)).operations ?? []; + const pullRequestOperations = operations + .filter(operation => operation.id.startsWith('create-pr') || operation.id === 'create-draft-pr') + .map(operation => ({ id: operation.id, group: operation.group, scopes: operation.scopes })); + + assert.deepStrictEqual(pullRequestOperations, [ + { id: 'create-pr', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-pr-auto-merge', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-pr-auto-squash', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-pr-auto-rebase', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-draft-pr', group: 'pull-request_draft', scopes: ['changeset'] }, + ]); + }); + + parityTest('enabling Agent Merge adds and removes its pull request operation', async function () { + const workspace = createGitWorkspace('ahp-changeset-agent-merge-'); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/microsoft/vscode.git'], { cwd: workspace }); + const sessionUri = await createSessionIn(workspace, 'changeset-agent-merge'); + const uncommittedUri = buildUncommittedChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: uncommittedUri }); + await driveTurnToCompletion(context.client, sessionUri, 'turn-changeset-agent-merge-materialize', 'Reply exactly "ready".', nextClientSeq()); + await runBangTurn(sessionUri, 'turn-changeset-agent-merge', writeFileCommand('agent-merge.txt', 'AGENT MERGE'), nextClientSeq()); + await waitForOperation(uncommittedUri, 'create-pr'); + + try { + await setRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const operation = await waitForOperation(uncommittedUri, 'create-pr-agent-merge'); + assert.deepStrictEqual({ + id: operation.id, + group: operation.group, + scopes: operation.scopes, + }, { + id: 'create-pr-agent-merge', + group: 'pull-request', + scopes: ['changeset'], + }); + } finally { + await setRootConfig({ [AgentMergeConfigKey.Enabled]: false }); + } + + await waitForOperationRemoved(uncommittedUri, 'create-pr-agent-merge'); + }); + + conformanceTest(context, 'a folder session advertises commit on its branch changeset', async function () { + const workspace = createGitWorkspace('ahp-changeset-branch-commit-'); + const sessionUri = await createSessionIn(workspace, 'changeset-branch-commit'); + const branchUri = buildBranchChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: branchUri }); + await runBangTurn(sessionUri, 'turn-changeset-branch-commit', writeFileCommand('branch-commit.txt', 'COMMIT'), 1); + + const operation = await waitForOperation(branchUri, 'commit'); + + assert.deepStrictEqual({ + id: operation.id, + group: operation.group, + scopes: operation.scopes, + }, { + id: 'commit', + group: 'commit', + scopes: ['changeset'], + }); + }); + conformanceTest(context, 'a branch with an upstream and no outgoing commits omits sync', async function () { const { workspace } = createRemoteGitWorkspace('ahp-sync-none'); const sessionUri = await createSessionIn(workspace, 'sync-none'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/detachedWorktreeSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/detachedWorktreeSuite.ts new file mode 100644 index 00000000000000..1bba064842280e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/detachedWorktreeSuite.ts @@ -0,0 +1,305 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Detached worktrees: a git worktree the host materializes *before* any session + * owns it. + * + * A client that wants a worktree ready before the user sends their first + * message (the Agents window does this so a draft session already has a + * checkout) asks the host for one through the `vscode/…DetachedWorktree` + * extension methods. The worktree is keyed by an opaque handle rather than by a + * session, so its whole lifecycle — materialize, claim, archive/unarchive, + * delete, reconcile — is addressable over the protocol without a turn ever + * running. + * + * Everything here is host-local: git commands against a temporary repository + * plus the host's own per-handle record. Nothing crosses the model boundary, so + * every scenario is registered as a conformance-tier host-only test and runs + * against the strict shared empty fixture. + */ + +import assert from 'assert'; +import { execFileSync } from 'child_process'; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { getComparisonKey } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { + ClaimAgentHostDetachedWorktreeExtensionMethod, + CreateAgentHostDetachedWorktreeExtensionMethod, + DeleteAgentHostDetachedWorktreeExtensionMethod, + ReconcileAgentHostDetachedWorktreesExtensionMethod, + SetAgentHostDetachedWorktreeArchivedExtensionMethod, + type IAgentHostExtensionCommandMap, +} from '../../../../common/agentHostExtensionProtocol.js'; +import { isAgentDevContainerWorktreeHandle } from '../../../../common/meta/agentDevContainerWorktreeMeta.js'; +import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { ROOT_STATE_URI, SessionLifecycle, type SessionState } from '../../../../common/state/sessionState.js'; +import { initTestGitRepo, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { vscodeAgentHostTarget } from '../harness/agentHostTarget.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +type CreateDetachedWorktreeResult = IAgentHostExtensionCommandMap[typeof CreateAgentHostDetachedWorktreeExtensionMethod]['result']; + +/** The `agents/` prefix the host puts in front of every branch it generates for an isolated checkout. */ +const AGENT_BRANCH_PREFIX = 'agents/'; + +/** + * Resolves a path through symlinks when it exists, and returns it unchanged + * when it does not. Temp directories are symlinked on macOS (`/var` -> + * `/private/var`), and these tests compare paths that git printed against paths + * the host returned, so both sides have to be canonicalized the same way — + * including after a worktree has been removed, when the path no longer resolves. + */ +function canonicalPath(candidate: string): string { + try { + return realpathSync(candidate); + } catch { + return candidate; + } +} + +function pathComparisonKey(candidate: string): string { + return getComparisonKey(URI.file(canonicalPath(candidate))); +} + +export function defineDetachedWorktreeTests(context: IAgentHostE2ETestContext): void { + // The detached-worktree family is an AHP *extension* method set rather than + // part of the core protocol, so only the VS Code agent host answers it. + if (context.targetId !== vscodeAgentHostTarget.id) { + return; + } + + const { config, createdSessions, tempDirs } = context; + const enabled = config.supportsWorktreeIsolation; + + let clientOrdinal = 0; + + /** A git repository with one commit, so a worktree has a branch point to check out. */ + function createGitWorkspace(prefix: string): string { + // Canonicalized up front: the host resolves the repository root through + // git, which reports the real path, and the worktree container is derived + // from that root. + const workspace = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(workspace, `${workspace}.worktrees`); + initTestGitRepo(workspace); + writeFileSync(join(workspace, 'seed.txt'), 'seed\n'); + execFileSync('git', ['add', '.'], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: workspace }); + return workspace; + } + + function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + } + + /** The worktrees git currently has registered for `repository`, canonicalized. */ + function registeredWorktrees(repository: string): string[] { + return git(repository, 'worktree', 'list', '--porcelain') + .split('\n') + .filter(line => line.startsWith('worktree ')) + .map(line => pathComparisonKey(line.slice('worktree '.length).trim())); + } + + function isRegisteredWorktree(repository: string, worktreePath: string): boolean { + return registeredWorktrees(repository).includes(pathComparisonKey(worktreePath)); + } + + function branchExists(repository: string, branchName: string): boolean { + return git(repository, 'branch', '--list', branchName).length > 0; + } + + /** + * Creates a session configured for worktree isolation and stops before the + * first turn, which is exactly the state a detached worktree is requested + * from: the host has a session record but has deliberately not resolved its + * working directory yet. + */ + async function createUnstartedWorktreeSession(workspace: string, prefix: string): Promise { + context.client.setWorkingDirectory(workspace); + await context.client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `${prefix}-${config.provider}-${clientOrdinal++}`, + }, 30_000); + await context.client.call('authenticate', { + channel: ROOT_STATE_URI, + resource: 'https://api.github.com', + token: config.githubToken ?? resolveGitHubToken(), + }, 30_000); + + const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); + await context.client.call('createSession', { + channel: sessionUri, + provider: config.provider, + workingDirectories: [URI.file(workspace).toString()], + config: { isolation: 'worktree', branch: git(workspace, 'branch', '--show-current') }, + }, 30_000); + createdSessions.push(sessionUri); + return sessionUri; + } + + function createDetachedWorktree(session: string, prompt: string): Promise { + return context.client.call(CreateAgentHostDetachedWorktreeExtensionMethod, { session, prompt }, 60_000); + } + + function claimDetachedWorktree(handle: string): Promise { + return context.client.call(ClaimAgentHostDetachedWorktreeExtensionMethod, { handle }, 30_000); + } + + function setDetachedWorktreeArchived(handle: string, archived: boolean): Promise { + return context.client.call(SetAgentHostDetachedWorktreeArchivedExtensionMethod, { handle, archived }, 60_000); + } + + function deleteDetachedWorktree(handle: string): Promise { + return context.client.call(DeleteAgentHostDetachedWorktreeExtensionMethod, { handle }, 60_000); + } + + function reconcileDetachedWorktrees(scope: string, activeHandles: readonly string[]): Promise { + return context.client.call(ReconcileAgentHostDetachedWorktreesExtensionMethod, { scope, activeHandles: [...activeHandles] }, 60_000); + } + + conformanceTest(context, 'creating a detached worktree materializes a checkout for an unstarted session', async function () { + const workspace = createGitWorkspace('ahp-detached-create-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-create'); + const sessionState = (await context.client.call('subscribe', { channel: sessionUri })).snapshot!.state as SessionState; + + const created = await createDetachedWorktree(sessionUri, 'summarize the seed file'); + const worktreePath = URI.parse(created.resource).fsPath; + + // The handle is opaque to the client, the checkout is a real git worktree + // of the session's repository, and it carries the repository's content — + // the three things a client needs before it can hand the directory to a + // user. The session itself stays unstarted: a detached worktree is not + // (yet) anybody's working directory. + assert.deepStrictEqual({ + sessionLifecycle: sessionState.lifecycle, + handleIsOpaqueId: isAgentDevContainerWorktreeHandle(created.handle), + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + checkedOutRepositoryContent: existsSync(join(worktreePath, 'seed.txt')), + onGeneratedAgentBranch: git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD').startsWith(AGENT_BRANCH_PREFIX), + isSeparateFromWorkspace: pathComparisonKey(worktreePath) !== pathComparisonKey(workspace), + }, { + sessionLifecycle: SessionLifecycle.Creating, + handleIsOpaqueId: true, + existsOnDisk: true, + registeredWithGit: true, + checkedOutRepositoryContent: true, + onGeneratedAgentBranch: true, + isSeparateFromWorkspace: true, + }); + }, enabled); + + conformanceTest(context, 'reconciling detached worktrees keeps every handle inside its retention window', async function () { + const workspace = createGitWorkspace('ahp-detached-reconcile-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-reconcile'); + + const held = await createDetachedWorktree(sessionUri, 'reconcile the held checkout'); + const dropped = await createDetachedWorktree(sessionUri, 'reconcile the dropped checkout'); + const heldPath = URI.parse(held.resource).fsPath; + const droppedPath = URI.parse(dropped.resource).fsPath; + await claimDetachedWorktree(held.handle); + + // Omitted handles remain claimable until the retention grace period expires. + await reconcileDetachedWorktrees(getComparisonKey(URI.parse(held.resource)), [held.handle]); + await reconcileDetachedWorktrees(getComparisonKey(URI.parse(dropped.resource)), []); + + await claimDetachedWorktree(dropped.handle); + + assert.deepStrictEqual({ + heldExists: existsSync(heldPath), + droppedExists: existsSync(droppedPath), + heldRegistered: isRegisteredWorktree(workspace, heldPath), + droppedRegistered: isRegisteredWorktree(workspace, droppedPath), + areDistinctCheckouts: pathComparisonKey(heldPath) !== pathComparisonKey(droppedPath), + }, { + heldExists: true, + droppedExists: true, + heldRegistered: true, + droppedRegistered: true, + areDistinctCheckouts: true, + }); + }, enabled); + + conformanceTest(context, 'archiving a detached worktree removes its checkout and unarchiving recreates it', async function () { + const workspace = createGitWorkspace('ahp-detached-archive-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-archive'); + + const created = await createDetachedWorktree(sessionUri, 'archive and restore this checkout'); + const worktreePath = URI.parse(created.resource).fsPath; + const branchName = git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'); + + // Archiving reclaims the disk a dormant checkout is holding, but it must + // preserve the branch: that branch is the only thing that makes the + // checkout reconstructible, so dropping it would turn "archive" into + // "discard". + await setDetachedWorktreeArchived(created.handle, true); + const archived = { + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + branchPreserved: branchExists(workspace, branchName), + }; + + // Unarchiving puts the same branch back at the same path, so a client that + // stored the path before archiving still resolves to a valid checkout. + await setDetachedWorktreeArchived(created.handle, false); + const restored = { + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + checkedOutRepositoryContent: existsSync(join(worktreePath, 'seed.txt')), + branch: git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'), + }; + + assert.deepStrictEqual({ archived, restored }, { + archived: { + existsOnDisk: false, + registeredWithGit: false, + branchPreserved: true, + }, + restored: { + existsOnDisk: true, + registeredWithGit: true, + checkedOutRepositoryContent: true, + branch: branchName, + }, + }); + }, enabled); + + conformanceTest(context, 'deleting a detached worktree removes its checkout and forgets its handle', async function () { + const workspace = createGitWorkspace('ahp-detached-delete-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-delete'); + + const created = await createDetachedWorktree(sessionUri, 'delete this checkout'); + const worktreePath = URI.parse(created.resource).fsPath; + + await deleteDetachedWorktree(created.handle); + + // Deletion takes the checkout off disk *and* drops the host's record for + // the handle. The record is not directly observable, so the oracle is the + // handle no longer resolving — a client cannot claim what the host forgot. + await assert.rejects(claimDetachedWorktree(created.handle), /Unknown detached worktree handle/); + await assert.rejects(claimDetachedWorktree(generateUuid()), /Unknown detached worktree handle/); + + // Deletion is idempotent: a client retrying after a dropped response, or + // two clients reacting to the same removal, must not turn the second + // attempt into an error. + await deleteDetachedWorktree(created.handle); + + assert.deepStrictEqual({ + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + repositoryStillIntact: existsSync(join(workspace, 'seed.txt')), + }, { + existsOnDisk: false, + registeredWithGit: false, + repositoryStillIntact: true, + }); + }, enabled); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts index 9a7102e322dec2..68db36d023547d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts @@ -569,7 +569,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); }); - if (config.provider === 'claude') { + if (config.provider === 'claude' || config.provider === 'copilotcli') { test('file edit before and after content can be read from session storage', async function () { this.timeout(180_000); const workspace = mkdtempSync(join(tmpdir(), 'ahp-session-db-file-edit-')); @@ -582,7 +582,9 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don context.client, sessionUri, turnId, - 'Replace the complete contents of stored-edit.txt with AFTER_STORED_VALUE using your file edit tool; do not run a shell command. Then reply exactly "done".', + config.provider === 'copilotcli' + ? `Use edit exactly once to replace BEFORE_STORED_VALUE with AFTER_STORED_VALUE in ${join(workspace, 'stored-edit.txt')}. Do not inspect or search for the file and do not run a shell command. Then reply exactly "done".` + : 'Replace the complete contents of stored-edit.txt with AFTER_STORED_VALUE using your file edit tool; do not run a shell command. Then reply exactly "done".', 1, ); const edit = context.client.receivedNotifications(n => diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts index af203abb603ff2..2d5da44e2c60e5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts @@ -11,10 +11,12 @@ import { join } from '../../../../../../base/common/path.js'; import { basename, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentHostConfigKey } from '../../../../common/agentHostCustomizationConfig.js'; import { AgentHostCopilotMultiRootEnabledConfigKey } from '../../../../common/agentHostSchema.js'; +import { deriveGitHubEndpoints, gitHubCopilotResource } from '../../../../common/githubEndpoints.js'; import { CompletionItemKind, type CompletionsResult, type InitializeResult, type ResolveSessionConfigResult, type SessionConfigCompletionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ActionType } from '../../../../common/state/sessionActions.js'; +import { ActionType, AuthRequiredReason, type AuthRequiredParams } from '../../../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageAttachmentKind, ROOT_STATE_URI, ToolCallConfirmationReason, type TerminalState, type ToolResultContent } from '../../../../common/state/sessionState.js'; import { createRealSession, @@ -140,6 +142,26 @@ export function defineHostFeaturesTests(context: IAgentHostE2ETestContext): void }); }); + conformanceTest(context, 'configuring a GitHub Enterprise host asks the client to re-authenticate', async function () { + const enterpriseUri = 'https://enterprise.example.com'; + await createSession('enterprise-auth-required'); + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + context.client.clearReceived(); + try { + const required = context.client.waitForNotification(notification => notification.method === 'auth/required'); + await setRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: enterpriseUri }); + const notification = await required; + + assert.deepStrictEqual(notification.params as AuthRequiredParams, { + channel: ROOT_STATE_URI, + resource: gitHubCopilotResource(deriveGitHubEndpoints(enterpriseUri)), + reason: AuthRequiredReason.Required, + }); + } finally { + await setRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: '' }); + } + }); + conformanceTest(context, 'workspace file completions are filtered, attached, and cached', async function () { const workspace = createWorkspace('ahp-file-completions-'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index 04ddaa514b69f0..9fd2eaaf647d11 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -10,10 +10,13 @@ import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey } from '../../../../common/agentHostSchema.js'; import { FEEDBACK_ANNOTATION_META_KEY, type IFeedbackAnnotationMeta } from '../../../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../../../common/annotationsUri.js'; import { buildOpenSessionLinkUri } from '../../../../common/openSessionLink.js'; -import { SessionServerToolName } from '../../../../common/serverToolNames.js'; +import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; +import { ArtifactServerToolName, SessionServerToolName } from '../../../../common/serverToolNames.js'; +import { readSessionArtifacts } from '../../../../common/sessionArtifacts.js'; import type { ListSessionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType, NotificationType, type ChatToolCallCompleteAction, type ChatToolCallStartAction, type SessionAddedParams, type StateAction } from '../../../../common/state/sessionActions.js'; import { @@ -101,7 +104,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void return { sessionUri, chatUri, workspace }; } - async function createSession(prefix: string, stableResource = false): Promise { + async function createSession(prefix: string, stableResource = false, beforeCreateSession?: () => Promise): Promise { const workspace = mkdtempSync(join(tmpdir(), `ahp-server-tools-${prefix}-`)); tempDirs.push(workspace); if (!stableResource) { @@ -111,6 +114,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void `server-tools-${prefix}-${config.provider}`, createdSessions, URI.file(workspace), + beforeCreateSession, ); context.client.clearReceived(); return { sessionUri, chatUri: buildDefaultChatUri(sessionUri), workspace }; @@ -157,6 +161,11 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void ); } + async function setRootConfig(values: Readonly>): Promise { + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + await dispatchAndWait(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: values }); + } + async function seedFeedback(sessionUri: string, options: ISeedFeedbackOptions): Promise { const annotationsUri = buildAnnotationsUri(sessionUri); await context.client.call('subscribe', { channel: annotationsUri }); @@ -267,6 +276,148 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void assert.deepStrictEqual(toolNames, [...feedbackToolNames, ...sessionToolNames]); }); + serverToolTest('server tool: rename_chat renames the chat it runs in', async function () { + try { + const session = await createSession('rename-chat', false, () => setRootConfig({ + [AgentHostActiveAgentTitleGenerationConfigKey]: true, + })); + await driveTurnToCompletion( + context.client, + session.sessionUri, + 'turn-rename-chat-seed', + '/rename Seeded Chat', + reserveClientSequenceBlock(), + ); + const { tool } = await driveServerTool( + session, + 'turn-rename-chat', + 'Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed".', + SessionServerToolName.RenameChat, + ); + const renamed = await retry(async () => { + const sessionTitle = (await sessionState(session.sessionUri)).title; + const chatTitle = (await chatState(session.chatUri)).title; + if (sessionTitle !== 'Coverage audit' || chatTitle !== 'Coverage audit') { + throw new Error('The chat rename has not completed'); + } + return { sessionTitle, chatTitle }; + }, 100, 100); + + assert.deepStrictEqual({ + succeeded: tool.completion.result.success, + ...renamed, + }, { + succeeded: true, + sessionTitle: 'Coverage audit', + chatTitle: 'Coverage audit', + }); + } finally { + await setRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: false }); + } + }); + + serverToolTest('server tool: add_artifact_or_reference records a reference in session state', async function () { + try { + const session = await createSession('artifact-add', false, () => setRootConfig({ + [AgentHostArtifactToolsConfigKey]: true, + })); + await driveServerTool( + session, + 'turn-artifact-add', + 'Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded".', + ArtifactServerToolName.AddArtifactOrReference, + { result: [/Added reference:/, /Agent Host guide/, /https:\/\/example\.com\/agent-host/] }, + ); + const [artifact] = readSessionArtifacts((await sessionState(session.sessionUri))._meta); + + assert.deepStrictEqual({ + artifact: artifact && { + type: artifact.type, + label: artifact.label, + isArtifact: artifact.isArtifact, + link: artifact.link, + }, + }, { + artifact: { + type: 'website', + label: 'Agent Host guide', + isArtifact: false, + link: 'https://example.com/agent-host', + }, + }); + } finally { + await setRootConfig({ [AgentHostArtifactToolsConfigKey]: false }); + } + }); + + serverToolTest('server tool: add_artifact_or_reference rejects a session-management link', async function () { + try { + const session = await createSession('artifact-reject-session', false, () => setRootConfig({ + [AgentHostArtifactToolsConfigKey]: true, + })); + const { tool } = await driveServerTool( + session, + 'turn-artifact-reject-session', + 'Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected".', + ArtifactServerToolName.AddArtifactOrReference, + { + success: false, + result: [/sessions and chats created with session-management tools must not be recorded/], + }, + ); + + assert.deepStrictEqual({ + succeeded: tool.completion.result.success, + artifacts: readSessionArtifacts((await sessionState(session.sessionUri))._meta), + }, { + succeeded: false, + artifacts: [], + }); + } finally { + await setRootConfig({ [AgentHostArtifactToolsConfigKey]: false }); + } + }); + + serverToolTest('server tool: list and remove round-trip a recorded reference', async function () { + try { + const session = await createSession('artifact-list-remove', false, () => setRootConfig({ + [AgentHostArtifactToolsConfigKey]: true, + })); + await driveServerTool( + session, + 'turn-artifact-list-remove-add', + 'Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added".', + ArtifactServerToolName.AddArtifactOrReference, + ); + const [artifact] = readSessionArtifacts((await sessionState(session.sessionUri))._meta); + assert.ok(artifact); + const listed = await driveServerTool( + session, + 'turn-artifact-list-remove-list', + 'Call list_artifacts_and_references exactly once, then reply with exactly "listed".', + ArtifactServerToolName.ListArtifactsAndReferences, + ); + const removed = await driveServerTool( + session, + 'turn-artifact-list-remove-remove', + `Call remove_artifact_or_reference exactly once with id "${artifact.id}", then reply with exactly "removed".`, + ArtifactServerToolName.RemoveArtifactOrReference, + ); + + assert.deepStrictEqual({ + listed: listed.tool.resultText.includes(`${artifact.id} (website, reference) Design notes — https://example.com/design`), + removed: removed.tool.resultText.includes(`Removed reference: ${artifact.id}`), + artifacts: readSessionArtifacts((await sessionState(session.sessionUri))._meta), + }, { + listed: true, + removed: true, + artifacts: [], + }); + } finally { + await setRootConfig({ [AgentHostArtifactToolsConfigKey]: false }); + } + }); + serverToolTest('server tool: listComments executes in-process with an empty annotation channel', async function () { const session = await createSession('comments-empty'); const { tool } = await driveServerTool( @@ -875,9 +1026,11 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void return request; }, 50, 600); const childState = await waitForChatIdle(buildDefaultChatUri(child.resource)); + const childSessionState = await sessionState(child.resource); assert.deepStrictEqual({ sawPendingConfirmation: turn.sawPendingConfirmation, provider: child.provider, + isolation: childSessionState.config?.values[SessionConfigKey.Isolation], messages: childState.turns.map(turn => turn.message.text), title: childState.title, childRequestModel: childRequest.model, @@ -885,6 +1038,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void }, { sawPendingConfirmation: true, provider: model.provider, + isolation: 'folder', messages: [childPrompt], title: 'Created Child', childRequestModel: createSessionModelWireTarget, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 21ad491425261f..f789ee53d7df0b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -7,11 +7,15 @@ import assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; import { retry, timeout } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; -import { ActionType } from '../../../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, type ChatState, type SessionState } from '../../../../common/state/sessionState.js'; +import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; +import type { ListSessionsResult, ResourceReadResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; +import type { SessionSummaryChangedParams } from '../../../../common/state/protocol/channels-root/notifications.js'; +import { ActionType, type ChatToolCallCompleteAction } from '../../../../common/state/sessionActions.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, ToolResultContentType, type ChatState, type SessionState, type ToolResultFileEditContent } from '../../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { createRealSession, driveTurnToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; @@ -19,6 +23,7 @@ import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; const RECORDING = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1'; +const RUN_KNOWN_ISSUES = process.env['AGENT_HOST_RUN_KNOWN_ISSUES'] === '1'; export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext): void { if (context.tier !== 'parity') { @@ -131,6 +136,104 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) }); }); + if (config.provider === 'copilotcli') { + (RUN_KNOWN_ISSUES ? test : test.skip)('file edit metadata survives a host restart', async function () { + this.timeout(240_000); + const workspace = fs.mkdtempSync(`${tmpdir()}/ahp-persistence-file-edit-`); + tempDirs.push(workspace); + const filePath = join(workspace, 'stored-edit.txt'); + fs.writeFileSync(filePath, 'BEFORE_RESTART'); + const sessionUri = await createRealSession(context.client, config, 'persistence-file-edit', createdSessions, URI.file(workspace)); + await driveTurnToCompletion(context.client, sessionUri, 'turn-persistence-file-edit-seed', 'Reply exactly "READY".', 1); + + await restartAndInitialize('persistence-file-edit', workspace); + await context.client.call('subscribe', { channel: sessionUri }); + await context.client.call('subscribe', { channel: buildDefaultChatUri(sessionUri) }); + context.client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }, + }); + await context.client.waitForNotification(n => + isActionNotification(n, 'session/configChanged') + && getActionEnvelope(n).channel === sessionUri, + ); + context.client.clearReceived(); + const turnId = 'turn-persistence-file-edit'; + await driveTurnToCompletion( + context.client, + sessionUri, + turnId, + `Use edit exactly once to replace BEFORE_RESTART with AFTER_RESTART in ${filePath}. Do not inspect or search for the file and do not run a shell command. Then reply exactly "done".`, + 2, + ); + + const edit = context.client.receivedNotifications(n => + isActionNotification(n, 'chat/toolCallComplete') + && getActionEnvelope(n).channel === buildDefaultChatUri(sessionUri) + && (getActionEnvelope(n).action as ChatToolCallCompleteAction).turnId === turnId, + ).flatMap(n => (getActionEnvelope(n).action as ChatToolCallCompleteAction).result.content ?? []) + .find((content): content is ToolResultFileEditContent => content.type === ToolResultContentType.FileEdit); + assert.ok(edit?.before?.content.uri); + assert.ok(edit.after?.content.uri); + + const [before, after] = await Promise.all([ + context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: edit.before.content.uri, + encoding: ContentEncoding.Utf8, + }), + context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: edit.after.content.uri, + encoding: ContentEncoding.Utf8, + }), + ]); + assert.deepStrictEqual({ before: before.data, after: after.data }, { + before: 'BEFORE_RESTART', + after: 'AFTER_RESTART', + }); + }); + } + + test('archiving a never-restored session survives a host restart', async function () { + this.timeout(240_000); + const workspace = fs.mkdtempSync(`${tmpdir()}/ahp-archive-unrestored-`); + tempDirs.push(workspace); + const sessionUri = await createRealSession(context.client, config, `archive-unrestored-${config.provider}`, createdSessions, URI.file(workspace)); + await driveTurnToCompletion(context.client, sessionUri, 'turn-archive-unrestored-seed', 'Reply exactly "READY".', 1); + await restartAndInitialize(`archive-unrestored-reconnect-${config.provider}`, workspace); + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const before = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + assert.strictEqual(before.items.some(item => item.resource === sessionUri), true); + context.client.clearReceived(); + context.client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { type: ActionType.SessionIsArchivedChanged, isArchived: true }, + }); + await context.client.waitForNotification(notification => + notification.method === 'root/sessionSummaryChanged' + && (notification.params as SessionSummaryChangedParams).session === sessionUri + && (((notification.params as SessionSummaryChangedParams).changes.status ?? 0) & SessionStatus.IsArchived) !== 0, + ); + + await restartAndInitialize(`archive-unrestored-verify-${config.provider}`, workspace); + const after = await context.client.call('listSessions', { channel: ROOT_STATE_URI, includeArchived: true }); + const restored = after.items.find(item => item.resource === sessionUri); + + assert.deepStrictEqual({ + restored: restored !== undefined, + isArchived: restored !== undefined && (restored.status & SessionStatus.IsArchived) !== 0, + }, { + restored: true, + isArchived: true, + }); + }); + const peerChatPersistenceEnabled = config.supportsMultipleChats && (config.supportsMultipleChatsE2E !== false || RECORDING) && (!(context.isWindows && config.provider === 'copilotcli') || context.runKnownIssueTests); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index d186581be34e18..d9505fe64fcabd 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -456,6 +456,10 @@ export function isInMemoryStorageScope(scope: BrowserViewStorageScope): boolean return scope === BrowserViewStorageScope.Ephemeral || scope === BrowserViewStorageScope.Agent; } +export function isBrowserViewStorageScopeShareableWithAgent(scope: BrowserViewStorageScope, networkFilteringEnabled: boolean): boolean { + return !networkFilteringEnabled || scope === BrowserViewStorageScope.Agent; +} + /** Selects an existing browser context by ID or resolves one from storage options. */ export type BrowserViewSessionSelector = string | IBrowserViewSessionOptions; diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 9184d834da4968..8be17ee4308ed0 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -19,6 +19,7 @@ import { BrowserSessionRemote, IBrowserSessionRemote } from './browserSessionRem import { FileAccess, Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { localize } from '../../../nls.js'; +import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; /** * Holds an Electron session along with its storage scope and unique browser @@ -66,8 +67,8 @@ export class BrowserSession { * Cleans up stale {@link _byId} entries when the Electron session * they point to is garbage-collected. */ - private static readonly _finalizer = new FinalizationRegistry((id) => { - BrowserSession._byId.delete(id); + private static readonly _finalizer = new FinalizationRegistry(id => { + this._byId.delete(id); }); /** @@ -115,6 +116,18 @@ export class BrowserSession { return ids; } + /** Update network filtering on all live browser sessions. */ + static updateNetworkFiltering(): void { + for (const [id, ref] of BrowserSession._byId) { + const browserSession = ref.deref(); + if (browserSession) { + browserSession.updateNetworkFilter(); + } else { + BrowserSession._byId.delete(id); + } + } + } + /** * Get or create the singleton global-scope session. */ @@ -229,6 +242,7 @@ export class BrowserSession { private readonly _history: BrowserSessionHistory; private readonly _remote: BrowserSessionRemote; private readonly _permissions: BrowserSessionPermissions; + private _networkFilterEnabled = false; /** * @deprecated Don't use this directly. Create sessions via the static factory methods. @@ -244,11 +258,13 @@ export class BrowserSession { readonly electronSession: Electron.Session, /** Resolved storage scope. */ readonly storageScope: BrowserViewStorageScope, + @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { this._trust = new BrowserSessionTrust(this); this._history = new BrowserSessionHistory(this); this._remote = new BrowserSessionRemote(this); this._permissions = new BrowserSessionPermissions(this); + this.updateNetworkFilter(); this.configure(); BrowserSession.knownSessions.add(electronSession); BrowserSession._bySession.set(electronSession, this); @@ -289,7 +305,32 @@ export class BrowserSession { } /** - * Apply the permission policy and preload scripts to the session. + * Dynamically apply network filtering to Agent sessions. + */ + private updateNetworkFilter(): void { + if (this.storageScope !== BrowserViewStorageScope.Agent) { + return; + } + + const enabled = this.agentNetworkFilterService.isEnabled(); + if (this._networkFilterEnabled === enabled) { + return; + } + this._networkFilterEnabled = enabled; + this.electronSession.webRequest.onBeforeRequest(enabled ? (details, callback) => { + let uri: URI; + try { + uri = URI.parse(details.url, true); + } catch { + callback({ cancel: true }); + return; + } + callback({ cancel: !this.agentNetworkFilterService.isUriAllowed(uri) }); + } : null); + } + + /** + * Apply permissions, protocols, and preload scripts to the session. */ private configure(): void { this._permissions.configure(this.electronSession); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index a42b41d8cdb700..75fb879442b9be 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -89,6 +89,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (this._isActive) { return; } + this._validateAgentGroupStorageScope(); this._isActive = true; const views = await this.browserViewMainService.getBrowserViews(this.targetContext.host.windowId); @@ -158,6 +159,9 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (!view) { throw new Error(`Browser view ${viewId} not found`); } + if (this.filter.audience?.type === 'agent') { + this.browserViewMainService.validateAgentAccess(view); + } this.views.set(view.id, view); this.knownContextIds.add(view.session.id); @@ -274,9 +278,17 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createTarget(url: string, browserContextId?: string): Promise { + this._validateAgentGroupStorageScope(); if (browserContextId && !this.knownContextIds.has(browserContextId)) { throw new Error(`Unknown browser context ${browserContextId}`); } + if (browserContextId && this.filter.audience?.type === 'agent') { + const browserSession = BrowserSession.get(browserContextId); + if (!browserSession) { + throw new Error(`Browser context ${browserContextId} no longer exists`); + } + this.browserViewMainService.validateAgentStorageScope(browserSession.storageScope); + } const target = await this.browserViewMainService.createTarget(url, { ...this.targetContext, @@ -317,6 +329,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createBrowserContext(): Promise { + this._validateAgentGroupStorageScope(); const contextId = generateUuid(); const sessionSelector = this.targetContext.session; const usesAgentStorage = typeof sessionSelector === 'string' @@ -330,6 +343,24 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I return browserSession.id; } + private _validateAgentGroupStorageScope(): void { + if (this.filter.audience?.type === 'agent') { + this.browserViewMainService.validateAgentStorageScope(this._getTargetStorageScope()); + } + } + + private _getTargetStorageScope(): BrowserViewStorageScope { + if (typeof this.targetContext.session === 'string') { + const browserSession = BrowserSession.get(this.targetContext.session); + if (!browserSession) { + throw new Error(`Browser session ${this.targetContext.session} not found`); + } + return browserSession.storageScope; + } + + return this.targetContext.session.scope; + } + async disposeBrowserContext(browserContextId: string): Promise { if (!this.ownedContextIds.has(browserContextId)) { throw new Error('Can only dispose browser contexts created by this group'); diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index d7676a4a66c9cb..b929bfa8cb9361 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { BrowserViewSessionSelector, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; +import { BrowserViewSessionSelector, BrowserViewStorageScope, isBrowserViewStorageScopeShareableWithAgent, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -25,6 +25,7 @@ import { BrowserViewInspectElementId } from './browserViewInspector.js'; import { equals } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; +import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -35,6 +36,12 @@ export interface IBrowserViewMainService extends IBrowserViewService { /** Create a new target and return it. */ createTarget(url: string, context: IBrowserViewCreationContext): Promise; + + /** Validate that a view can be exposed to an agent audience. */ + validateAgentAccess(view: BrowserView): void; + + /** Validate that a storage scope can be exposed to an agent. */ + validateAgentStorageScope(storageScope: BrowserViewStorageScope): void; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -68,8 +75,13 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService, @ILogService private readonly logService: ILogService, + @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { super(); + this._register(this.agentNetworkFilterService.onDidChange(() => { + BrowserSession.updateNetworkFiltering(); + this._updateAgentAccess(); + })); } async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { @@ -96,7 +108,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa if (browserSession) { return browserSession; } - return BrowserSession.getOrCreateEphemeral(this.instantiationService, id); + throw new Error(`Browser session ${selector} not found`); } const hostWindow = this.windowsMainService.getWindowById(hostWindowId); @@ -235,7 +247,21 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } async setAudience(id: string, audience: IBrowserViewAudience, enabled: boolean): Promise { - this._getBrowserView(id).setAudience(audience, enabled); + const view = this._getBrowserView(id); + if (enabled && audience.type === 'agent') { + this.validateAgentAccess(view); + } + view.setAudience(audience, enabled); + } + + validateAgentAccess(view: BrowserView): void { + this.validateAgentStorageScope(view.session.storageScope); + } + + validateAgentStorageScope(storageScope: BrowserViewStorageScope): void { + if (!isBrowserViewStorageScopeShareableWithAgent(storageScope, this.agentNetworkFilterService.isEnabled())) { + throw new Error('Browser session cannot be exposed to an agent because it does not enforce the current network policy.'); + } } async destroyBrowserView(id: string): Promise { @@ -474,7 +500,11 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private _createBrowserView(id: string, options: IBrowserViewCreateOptions, editorOpenRequest?: IBrowserViewEditorOpenOptions, electronOptions?: Electron.WebContentsViewConstructorOptions): BrowserView { + const hasAgentAccess = options.owner.type === 'agent' || options.initialAudiences?.some(audience => audience.type === 'agent') === true; const browserSession = this._resolveBrowserSession(id, options.host.windowId, options.session); + if (hasAgentAccess) { + this.validateAgentStorageScope(browserSession.storageScope); + } const view = this._createNativeBrowserView(id, options.host, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions); if (options.initialAudiences) { view.setAudiences(options.initialAudiences); @@ -495,6 +525,26 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view; } + private _updateAgentAccess(): void { + if (!this.agentNetworkFilterService.isEnabled()) { + return; + } + + const staleAgentViewIds: string[] = []; + for (const [, view] of this.browserViews) { + if (isBrowserViewStorageScopeShareableWithAgent(view.session.storageScope, true)) { + continue; + } + view.setAudience({ type: 'agent' }, false); + if (view.owner.type === 'agent') { + staleAgentViewIds.push(view.id); + } + } + for (const viewId of staleAgentViewIds) { + this.browserViews.deleteAndDispose(viewId); + } + } + private async openNew( url: string, context: IBrowserViewCreationContext, diff --git a/src/vs/platform/browserView/test/common/browserView.test.ts b/src/vs/platform/browserView/test/common/browserView.test.ts index ed064c59023f7d..333f75c2327c33 100644 --- a/src/vs/platform/browserView/test/common/browserView.test.ts +++ b/src/vs/platform/browserView/test/common/browserView.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; +import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isBrowserViewStorageScopeShareableWithAgent, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; suite('BrowserView', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -89,4 +89,24 @@ suite('BrowserView', () => { agent: true, }); }); + + test('only shares Agent storage when network filtering is enabled', () => { + assert.deepStrictEqual({ + filteringDisabled: Object.fromEntries(Object.values(BrowserViewStorageScope).map(scope => [scope, isBrowserViewStorageScopeShareableWithAgent(scope, false)])), + filteringEnabled: Object.fromEntries(Object.values(BrowserViewStorageScope).map(scope => [scope, isBrowserViewStorageScopeShareableWithAgent(scope, true)])), + }, { + filteringDisabled: { + global: true, + workspace: true, + ephemeral: true, + agent: true, + }, + filteringEnabled: { + global: false, + workspace: false, + ephemeral: false, + agent: true, + }, + }); + }); }); diff --git a/src/vs/platform/label/common/label.ts b/src/vs/platform/label/common/label.ts index aacf26817a0224..1541b3b84c0220 100644 --- a/src/vs/platform/label/common/label.ts +++ b/src/vs/platform/label/common/label.ts @@ -31,7 +31,7 @@ export interface ILabelService { /** Returns the display home containing `resource`. */ getUriHome(resource: URI): URI | undefined; - registerFormatter(formatter: ResourceLabelFormatter): IDisposable; + registerFormatter(formatter: ResourceLabelFormatter | ResourceLabelTemplateFormatter): IDisposable; readonly onDidChangeFormatters: Event; /** @@ -61,6 +61,21 @@ export interface ResourceLabelFormatter { formatting: ResourceLabelFormatting; } +export interface ResourceLabelTemplateFormatter { + /** URI home whose optional template parameters occupy an entire path segment, such as `/sessions/${sessionId}`. */ + home: URI; + /** Fires when state used by `formatting` changes and existing labels must be recomputed. */ + onDidChangeFormatting: Event; + /** Resolves formatting whose label is treated as literal text. */ + formatting(context: ResourceLabelFormattingContext): ResourceLabelFormatting | undefined; +} + +export interface ResourceLabelFormattingContext { + readonly resource: URI; + readonly home: URI; + readonly parameters: ReadonlyMap; +} + export interface ResourceLabelFormatting { label: string; // myLabel:/${path} separator: '/' | '\\' | ''; diff --git a/src/vs/platform/networkFilter/common/networkFilterService.ts b/src/vs/platform/networkFilter/common/networkFilterService.ts index 33edb836a90de7..cb16692eef1d54 100644 --- a/src/vs/platform/networkFilter/common/networkFilterService.ts +++ b/src/vs/platform/networkFilter/common/networkFilterService.ts @@ -37,6 +37,11 @@ export interface IAgentNetworkFilterService { */ isUriAllowed(uri: URI): boolean; + /** + * Returns whether network filtering is currently enabled. + */ + isEnabled(): boolean; + /** * Formats an error message for a blocked URI based on the current filter configuration. * @param uri The URI that was blocked. @@ -90,7 +95,7 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo isUriAllowed(uri: URI): boolean { // When domain filtering is inactive, allow all requests. - if (!this.shouldFilter()) { + if (!this.isEnabled()) { return true; } @@ -112,9 +117,8 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo return result; } - // Determines whether network filtering should be applied for a given request - // based on the global network filter setting. - private shouldFilter(): boolean { + + isEnabled(): boolean { return this.networkFilterEnabled; } diff --git a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts index a1dfec3686a188..7f8e66f3b870e2 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts @@ -140,6 +140,7 @@ suite('WebPageLoader', () => { const agentNetworkFilterService: IAgentNetworkFilterService = { _serviceBrand: undefined, onDidChange: Event.None, + isEnabled: () => true, isUriAllowed: isDomainAllowed ?? (() => true), formatError: (u) => `Access to ${u.authority} is blocked by network domain policy.`, }; diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 95c99ee8b79a9f..751fbad59c12c8 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -84,7 +84,7 @@ Session providers register internal per-session directories as resource label ho A custom view is mutually exclusive with the Sessions Part, grid Editor, Auxiliary Bar, and Panel. The title bar and Sidebar remain available. Covered parts retain desired visibility separately from effective grid visibility so their state can be restored when the custom view closes. -Opening a session dismisses the active custom view. On phone layouts, custom views participate in mobile navigation so platform back navigation dismisses them. +Explicit session and chat open actions dismiss the active custom view. Reactive fallback opens driven by session or chat lifecycle changes preserve the custom view while reconciling the hidden Sessions grid. On phone layouts, custom views participate in mobile navigation so platform back navigation dismisses them. ## Part lifecycle diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 6e924a37405f41..c97a4759304d37 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -83,6 +83,7 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { @ICommandService private readonly commandService: ICommandService, @IChangesViewService private readonly changesViewService: IChangesViewService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, ) { } createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel { @@ -111,6 +112,14 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { } return undefined; } + + openDiffEditor(original: URI, modified: URI): void { + void this.editorService.openEditor({ + original: { resource: original }, + modified: { resource: modified }, + options: { pinned: true }, + }); + } } class SessionChangesResourceLabel extends Disposable implements IResourceLabel { @@ -436,9 +445,7 @@ export class SessionChangesEditor extends AbstractEditorWithViewState